From 06bad2fe36df5519658a1fe2ff37d15978a56347 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:31:36 -0500 Subject: [PATCH 1/7] docs: bring CLAUDE.md in line with the current repositories and product rules Core's agent guidance still described modules living in this monorepo and manifest v7, and said nothing about the product rules that now govern every change. Record that first-party modules live in their own Zayin repositories and install as Module Packages, that the parent ROADMAP.md and DESIGN.md are authoritative for product direction, the ownership, branding, token, dark-mode and no-native-dialog rules, that display pairing is transitional until Stage 1, and the branch-per-PR, no-push-to-main, and no-release-tag workflow. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 72 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 43e2af8..ffdab92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,27 +6,60 @@ contents, because restated details drift. ## What this repository is -Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript -monorepo: +Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the +API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built +as a strictly layered pnpm/TypeScript monorepo: - **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration - execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a - feature module, a surface, or an app. -- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product - packages. They own their own data and migrations, declare capabilities/permissions through - `@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a - surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional - primary and full-viewport display artifacts, authored in React against the same role-neutral - `@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while - `pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source. + execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never + imports a feature module, a surface, or an app. +- **Modules** are independently versioned product packages. First-party modules live in **their own + repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages + (ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage + outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the + example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns + its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through + `@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a + surface. Manifest v8 permits independently optional primary and full-viewport display artifacts, + authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the + Module Database ABI v1 (ADR 0050). - **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every - presentation concern a module does not. They consume public API/client contracts only, never Core - or module internals directly. + presentation concern a module does not. The shell **hosts** module UI through the generic loader; + it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only. These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just documented. Read `docs/architecture/repository-boundaries.md` and `docs/architecture/platform-architecture.md` before proposing any change that crosses a layer. +## Product direction lives beside this repository + +The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the +parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard +`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into +this repository to make them available. They are authoritative for product direction: read the +relevant stages and sections before designing a change. If this repository's documentation disagrees +with them, flag the conflict, correct the repository-local documentation where appropriate, and never +silently follow the stale text. Architectural decisions still change only through ADRs (below). + +Rules from those documents that every change here must respect: + +- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and + devices, accounts and identity, roles and authorization, branding, mail, push, storage providers, + and module installation/lifecycle — belong to the platform, and modules consume them. +- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from + installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may. + Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy. +- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light, + dark, and system modes. Normal UI colours come only from the shared design tokens + (`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour. +- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited + in product UI. Destructive actions use in-product confirmation proportional to their impact. +- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard + sidebar's *Display devices* panel) and Signage's own display roster are a transitional + implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not + build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell + redesign early, and keep existing paired displays working across any change. + ## ADRs are authoritative `docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are @@ -43,6 +76,7 @@ At minimum, read the current versions of: - `docs/architecture/decisions/README.md` and every ADR relevant to your change - `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md` - `README.md` (current platform status — what is and is not implemented) +- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above) - `docs/development/repository-workflow.md` and `docs/development/testing.md` - `CONTRIBUTING.md` @@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat ## Working conventions -- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never - change branch-protection settings. +- The canonical repositories are on the church Gitea under the `Zayin` organization: + `ssh://git@192.168.68.76:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and + `Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags` + and branch from `origin/main`, never from whatever is checked out. +- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never + merge your own PR, and never change branch-protection settings. +- Never create or push a release tag. Release tags belong to the human maintainer. +- A change that needs both a platform contract change and a module change is two PRs: Core first, on + its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy + platform logic into a module to avoid making the correct Core change. - Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than burying them in an implementation. - Add or update tests when you change a contract or architectural rule. New behavior needs new -- 2.54.0 From 4bcdb578e689a7429058a601cace418debb80847 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:31:36 -0500 Subject: [PATCH 2/7] fix(display): keep a live display across reconnects, and return a revoked one to pairing Every browser 'online' event reset the display host to its loading state and mounted the module again: a fresh viewer session, a fresh stage, and every cached object URL released, on each Wi-Fi blip. And a device revoked while its display was open never noticed; it kept playing cached content and polling 403s until someone reloaded the page. The host now re-resolves its descriptor in the background (ADR 0043) and only replaces the frame when the answer actually changed: a new version or principal remounts, a refusal returns the screen to pairing (ADR 0036), and any other failure keeps the running display. A module request refused with 401/403 prompts the same revalidation, throttled, so revocation is noticed within one heartbeat. The offline indicator waits ten seconds before appearing, so a momentary blip shows nothing, and no longer names the platform on a lobby wall. Co-Authored-By: Claude Opus 5.5 --- surfaces/shell/src/display/DisplayApp.tsx | 170 +++++++++++-- surfaces/shell/src/display/DisplayUiFrame.tsx | 13 +- surfaces/shell/tests/display-app.test.tsx | 236 ++++++++++++++++++ 3 files changed, 400 insertions(+), 19 deletions(-) diff --git a/surfaces/shell/src/display/DisplayApp.tsx b/surfaces/shell/src/display/DisplayApp.tsx index e4f291f..dc385d6 100644 --- a/surfaces/shell/src/display/DisplayApp.tsx +++ b/surfaces/shell/src/display/DisplayApp.tsx @@ -1,6 +1,6 @@ import { designToken } from "@ekklesia/design-tokens"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; -import { type ReactElement, useEffect, useState } from "react"; +import { type ReactElement, useCallback, useEffect, useRef, useState } from "react"; import { bootstrapDisplayHost, type DisplayHostBootstrap, @@ -17,7 +17,13 @@ import { PairingScreen } from "./PairingScreen.js"; type DisplayState = | { readonly status: "loading"; readonly generation: number } - | { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } } + | { + readonly status: "ready"; + readonly bootstrap: DisplayHostBootstrap & { status: "ready" }; + /** Changes only when the mounted display must be replaced, never merely because the network + * came back. It keys the frame, so it is the one thing that can remount a running display. */ + readonly generation: number; + } /** No identity this display can use yet: show a pairing code rather than a human login form. */ | { readonly status: "pairing" } | { readonly status: "unavailable" } @@ -31,6 +37,54 @@ export interface DisplayAppProps { /** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point * supplies the real ones; omitting it leaves a display that simply never reports being offline. */ readonly connectivity?: DisplayConnectivitySource; + /** How long the display must stay offline before the corner indicator appears. Test seam only. */ + readonly offlineIndicatorDelayMs?: number; + /** The shortest interval between two revalidations prompted by refused module requests. Test seam + * only. */ + readonly revalidationIntervalMs?: number; +} + +/** A Wi-Fi reassociation or a switch reboot lasts seconds and needs no comment on a lobby wall. Only + * an outage that outlasts this is worth the corner indicator. */ +export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000; + +/** A module whose requests are being refused prompts at most one revalidation in this interval. A + * display that is merely missing one permission must not turn every refused request into two more. */ +export const DISPLAY_REVALIDATION_INTERVAL_MS = 15_000; + +/** Whether a fresh resolution describes exactly the display already mounted. Compared structurally: + * the descriptor and principal are small, server-produced, and carry nothing volatile, so any + * difference — a new version, a new contribution, a different principal — is a real one. */ +function sameDisplay(left: DisplayHostBootstrap, right: DisplayHostBootstrap): boolean { + return ( + JSON.stringify([left.descriptor, left.principal]) === + JSON.stringify([right.descriptor, right.principal]) + ); +} + +/** `value`, but only once it has been continuously true for `delayMs`. Becoming false is immediate. */ +function useSustained(value: boolean, delayMs: number): boolean { + const [sustained, setSustained] = useState(value && delayMs <= 0); + useEffect(() => { + if (!value) { + setSustained(false); + return; + } + if (delayMs <= 0) { + setSustained(true); + return; + } + const timer = setTimeout(() => setSustained(true), delayMs); + return () => clearTimeout(timer); + }, [delayMs, value]); + return sustained; +} + +function isDenied(error: unknown): boolean { + return ( + error instanceof HostProtocolError && + (error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED") + ); } const displayStyles = { @@ -86,11 +140,13 @@ const displayStyles = { * recovers on its own the moment connectivity returns. */ function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement { + // Neutral wording: this is on a wall the congregation reads, and the product name is the + // installation's to choose, not the platform's (ADR 0046). const label = props.connectivity.usingFallback ? props.connectivity.fallbackKind === "artifact" ? "Offline — showing the last downloaded display code" - : "Offline — showing the last downloaded Ekklesia display" - : "Offline — waiting for Ekklesia"; + : "Offline — showing the last downloaded display" + : "Offline — reconnecting"; return (
{label} @@ -130,7 +186,83 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { online: true, usingFallback: false, }); - const { config, connectivity: connectivitySource, importModule, moduleId } = props; + const { + config, + connectivity: connectivitySource, + importModule, + moduleId, + offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS, + revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS, + } = props; + const stateRef = useRef(state); + stateRef.current = state; + const revalidation = useRef<{ inFlight: AbortController | undefined; lastAt: number }>({ + inFlight: undefined, + lastAt: 0, + }); + + /** + * Resolves the descriptor again **without** taking the running display down (ADR 0043). + * + * A display that is showing something keeps showing it while the question is asked. Only the + * answer decides what happens: the same display means nothing changes at all; a new version or a + * different principal replaces the frame; a refusal is a revoked or expired device and returns the + * screen to pairing (ADR 0036); and any other failure is the network still settling, so the frame + * stays. A display that is not showing anything simply starts over. + */ + const revalidate = useCallback( + (reason: "online" | "authorization"): void => { + const current = stateRef.current; + if (current.status !== "ready") { + if (reason === "online") { + setAttempt((value) => value + 1); + } + return; + } + const tracker = revalidation.current; + const nowMs = Date.now(); + if ( + tracker.inFlight !== undefined || + (reason === "authorization" && nowMs - tracker.lastAt < revalidationIntervalMs) + ) { + return; + } + tracker.lastAt = nowMs; + const abort = new AbortController(); + tracker.inFlight = abort; + void bootstrapDisplayHost(config, moduleId, abort.signal) + .then((next) => { + const latest = stateRef.current; + if (abort.signal.aborted || latest.status !== "ready") { + return; + } + if (!sameDisplay(latest.bootstrap, next)) { + setState({ bootstrap: next, generation: latest.generation + 1, status: "ready" }); + } + }) + .catch((error: unknown) => { + if (!abort.signal.aborted && isDenied(error)) { + setState({ status: "pairing" }); + } + }) + .finally(() => { + if (tracker.inFlight === abort) { + tracker.inFlight = undefined; + } + }); + }, + [config, moduleId, revalidationIntervalMs], + ); + + useEffect( + () => () => { + revalidation.current.inFlight?.abort(); + revalidation.current.inFlight = undefined; + }, + [], + ); + + const onAuthorizationResponse = useCallback(() => revalidate("authorization"), [revalidate]); useEffect(() => { if (!connectivitySource) { @@ -142,27 +274,29 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { setConnectivity(next); // Nobody presses Retry on a lobby television. When the browser says the network is back, the // display resolves its descriptor again — which is also what stops a screen from sitting on - // cached code after the server returns, since the fresh descriptor names the current version - // and the network-first worker fetches it (ADR 0043). + // cached code after the server returns, since a fresh descriptor naming a new version replaces + // the frame and the network-first worker fetches it (ADR 0043). The same answer leaves the + // running display exactly where it was. if (next.online) { - setAttempt((current) => current + 1); + revalidate("online"); } }); return () => { unsubscribe(); monitor.close(); }; - }, [connectivitySource]); + }, [connectivitySource, revalidate]); useEffect(() => { const abort = new AbortController(); + revalidation.current.inFlight?.abort(); setState({ generation: attempt, status: "loading" }); void bootstrapDisplayHost(config, moduleId, abort.signal) .then((bootstrap) => { if (abort.signal.aborted) { return; } - setState({ bootstrap, status: "ready" }); + setState({ bootstrap, generation: attempt, status: "ready" }); }) .catch((error: unknown) => { if (abort.signal.aborted) { @@ -170,10 +304,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { } // A denied or unauthenticated descriptor is the ordinary state of a screen nobody has // paired yet, not an error worth logging on every wall in the building. - if ( - error instanceof HostProtocolError && - (error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED") - ) { + if (isDenied(error)) { setState({ status: "pairing" }); return; } @@ -188,12 +319,14 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { }, [attempt, config, moduleId]); const retry = (): void => setAttempt((current) => current + 1); + const showOffline = useSustained( + !connectivity.online || connectivity.usingFallback, + offlineIndicatorDelayMs, + ); return (
- {connectivity.online && !connectivity.usingFallback ? undefined : ( - - )} + {showOffline ? : undefined} {state.status === "loading" ? ( Loading display… ) : undefined} @@ -215,7 +348,8 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { config={config} descriptor={state.bootstrap.descriptor} {...(importModule ? { importModule } : {})} - key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`} + key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${state.generation}`} + onAuthorizationResponse={onAuthorizationResponse} principal={state.bootstrap.principal} /> ) : undefined} diff --git a/surfaces/shell/src/display/DisplayUiFrame.tsx b/surfaces/shell/src/display/DisplayUiFrame.tsx index d9a3792..3f458e9 100644 --- a/surfaces/shell/src/display/DisplayUiFrame.tsx +++ b/surfaces/shell/src/display/DisplayUiFrame.tsx @@ -35,6 +35,9 @@ export interface DisplayUiFrameProps { readonly principal: ModuleUiPrincipal; /** Test/composition seam only. Production uses the generic loader's native dynamic import. */ readonly importModule?: ModuleUiImporter; + /** Host-internal signal that a module request was refused, so the host can find out whether this + * device was revoked. It never enters the module-facing context. */ + readonly onAuthorizationResponse?: (status: 401 | 403) => void; } interface DisplayUiAttemptProps extends DisplayUiFrameProps { @@ -94,7 +97,10 @@ const frameStyles = { function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement { const containerRef = useRef(null); const [status, setStatus] = useState("loading"); - const { config, descriptor, importModule, onRetry, principal } = props; + const { config, descriptor, importModule, onAuthorizationResponse, onRetry, principal } = props; + // Read through a ref so a new callback identity can never remount a running display. + const authorizationRef = useRef(onAuthorizationResponse); + authorizationRef.current = onAuthorizationResponse; useEffect(() => { const container = containerRef.current; @@ -162,6 +168,11 @@ function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement { context: createModuleUiContext({ config, descriptor, + onAuthorizationResponse: (status) => { + if (!cancelled) { + authorizationRef.current?.(status); + } + }, principal, ...(importModule ? { importModule } : {}), }), diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index a5df29b..9899d4b 100644 --- a/surfaces/shell/tests/display-app.test.tsx +++ b/surfaces/shell/tests/display-app.test.tsx @@ -180,6 +180,12 @@ async function render( importModule?: (url: string) => Promise, moduleId = "announcements", connectivity?: DisplayConnectivitySource, + timing: { + readonly offlineIndicatorDelayMs?: number; + readonly revalidationIntervalMs?: number; + } = { + offlineIndicatorDelayMs: 0, + }, ): Promise { await act(async () => { root.render( @@ -187,6 +193,7 @@ async function render( config={config} {...(connectivity ? { connectivity } : {})} {...(importModule ? { importModule } : {})} + {...timing} moduleId={moduleId} />, ); @@ -926,3 +933,232 @@ describe("display host — offline behaviour", () => { expect(query("display-pairing")).not.toBeNull(); }); }); + +/** + * Stage 0 regressions: a running display survives its network, and a revoked one returns to pairing. + * + * Before these, every browser `online` event tore the module down and mounted it again (a fresh + * viewer session, a fresh stage, every cached object URL released), and a device revoked while its + * display was open kept playing until someone reloaded the page. + */ +describe("display host — continuity across reconnects and revocation", () => { + function controllableConnectivity() { + const listeners = new Map void>>(); + return { + source: { + addEventListener(type: "online" | "offline", listener: () => void) { + const existing = listeners.get(type) ?? new Set(); + existing.add(listener); + listeners.set(type, existing); + }, + initialOnline: true, + removeEventListener(type: "online" | "offline", listener: () => void) { + listeners.get(type)?.delete(listener); + }, + } satisfies DisplayConnectivitySource, + async emit(type: "online" | "offline") { + await act(async () => { + for (const listener of listeners.get(type) ?? []) { + listener(); + } + }); + }, + }; + } + + async function flush(): Promise { + await act(async () => { + for (let index = 0; index < 5; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }); + } + + /** A server whose descriptor answer can be changed mid-test, counting descriptor requests. */ + function changingServer() { + let descriptor: { status: number; body: unknown } = { body: descriptorPayload(), status: 200 }; + let descriptorCalls = 0; + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/v1/module-displays/announcements")) { + descriptorCalls += 1; + return json(descriptor.body, descriptor.status); + } + if (url.endsWith("/api/v1/device-pairing/requests")) { + return json( + { code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 }, + 201, + ); + } + if (url.endsWith("/api/v1/device-pairing/claims")) { + return json({ status: "pending" }); + } + if (url.endsWith("/api/v1/modules/announcements/refused")) { + return json({ code: "AUTHORIZATION_DENIED" }, 403); + } + return json({ code: "NOT_FOUND" }, 404); + }) as unknown as typeof globalThis.fetch; + return { + descriptorCalls: () => descriptorCalls, + fetch, + set(status: number, body: unknown) { + descriptor = { body, status }; + }, + }; + } + + function countingArtifact(version = "1.2.0") { + const counts = { mounts: 0, unmounts: 0 }; + let context: ModuleUiHostContext | undefined; + const importModule = async (url: string) => + artifact("announcements", url.includes("/2.0.0/") ? "2.0.0" : version, { + mount(element, mounted) { + counts.mounts += 1; + context = mounted; + element.textContent = "live"; + return () => { + counts.unmounts += 1; + }; + }, + }); + return { context: () => context, counts, importModule }; + } + + it("does not remount a live display when the network drops and returns", async () => { + const server = changingServer(); + const connectivity = controllableConnectivity(); + const display = countingArtifact(); + await render( + { apiBaseUrl, fetch: server.fetch }, + display.importModule, + "announcements", + connectivity.source, + ); + await flush(); + expect(display.counts.mounts).toBe(1); + const before = server.descriptorCalls(); + + await connectivity.emit("offline"); + await connectivity.emit("online"); + await flush(); + + // The descriptor is still resolved again (ADR 0043) — but the same answer changes nothing. + expect(server.descriptorCalls()).toBeGreaterThan(before); + expect(display.counts).toEqual({ mounts: 1, unmounts: 0 }); + expect(query("display-host-loading")).toBeNull(); + }); + + it("keeps the running display when the descriptor cannot be reached yet", async () => { + const server = changingServer(); + const connectivity = controllableConnectivity(); + const display = countingArtifact(); + await render( + { apiBaseUrl, fetch: server.fetch }, + display.importModule, + "announcements", + connectivity.source, + ); + await flush(); + + server.set(503, { code: "UNAVAILABLE" }); + await connectivity.emit("offline"); + await connectivity.emit("online"); + await flush(); + + expect(display.counts).toEqual({ mounts: 1, unmounts: 0 }); + expect(query("display-host-error")).toBeNull(); + }); + + it("replaces the display when the server now names a new version", async () => { + const server = changingServer(); + const connectivity = controllableConnectivity(); + const display = countingArtifact(); + await render( + { apiBaseUrl, fetch: server.fetch }, + display.importModule, + "announcements", + connectivity.source, + ); + await flush(); + + server.set(200, descriptorPayload("announcements", "2.0.0")); + await connectivity.emit("offline"); + await connectivity.emit("online"); + await flush(); + + expect(display.counts.unmounts).toBe(1); + expect(display.counts.mounts).toBe(2); + }); + + it("returns a device revoked while its display is open to pairing, without a reload", async () => { + const server = changingServer(); + const display = countingArtifact(); + await render({ apiBaseUrl, fetch: server.fetch }, display.importModule, "announcements"); + await flush(); + expect(query("display-module-container")).not.toBeNull(); + + // The administrator revokes the device. Its next module request is refused. + server.set(403, { code: "AUTHORIZATION_DENIED" }); + await act(async () => { + await display.context()?.api.request("refused"); + }); + await flush(); + + expect(display.counts.unmounts).toBe(1); + expect(query("display-pairing")).not.toBeNull(); + }); + + it("does not remount when a refused request turns out not to be a revocation", async () => { + const server = changingServer(); + const display = countingArtifact(); + await render( + { apiBaseUrl, fetch: server.fetch }, + display.importModule, + "announcements", + undefined, + { + offlineIndicatorDelayMs: 0, + revalidationIntervalMs: 60_000, + }, + ); + await flush(); + const before = server.descriptorCalls(); + + await act(async () => { + await display.context()?.api.request("refused"); + await display.context()?.api.request("refused"); + await display.context()?.api.request("refused"); + }); + await flush(); + + // One revalidation for the burst, and the display stays exactly where it was. + expect(server.descriptorCalls()).toBe(before + 1); + expect(display.counts).toEqual({ mounts: 1, unmounts: 0 }); + }); + + it("does not show the offline indicator for a momentary blip", async () => { + const server = changingServer(); + const connectivity = controllableConnectivity(); + await render( + { apiBaseUrl, fetch: server.fetch }, + countingArtifact().importModule, + "announcements", + connectivity.source, + { offlineIndicatorDelayMs: 60 }, + ); + await flush(); + + await connectivity.emit("offline"); + await connectivity.emit("online"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 120)); + }); + expect(query("display-offline")).toBeNull(); + + await connectivity.emit("offline"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 120)); + }); + expect(query("display-offline")?.textContent).toBe("Offline — reconnecting"); + }); +}); -- 2.54.0 From 85b1377fd3b10678e24462cb5ad8ee5c7b26c5fb Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:31:36 -0500 Subject: [PATCH 3/7] fix(devices): confirm before revoking a display Revoke acted on a single click, yet it strands the screen until someone re-pairs it in person. Explain the consequence in the shared in-product Dialog first (DESIGN 21.4), with an obvious Cancel. Co-Authored-By: Claude Opus 5.5 --- surfaces/shell/src/browser/DevicePanel.tsx | 44 ++++++++- surfaces/shell/tests/device-panel.test.tsx | 106 +++++++++++++++++++++ 2 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 surfaces/shell/tests/device-panel.test.tsx diff --git a/surfaces/shell/src/browser/DevicePanel.tsx b/surfaces/shell/src/browser/DevicePanel.tsx index 0e2a44d..15a9555 100644 --- a/surfaces/shell/src/browser/DevicePanel.tsx +++ b/surfaces/shell/src/browser/DevicePanel.tsx @@ -1,5 +1,5 @@ import { designToken } from "@ekklesia/design-tokens"; -import { Button, Field, TextInput } from "@ekklesia/ui"; +import { Button, Dialog, Field, TextInput } from "@ekklesia/ui"; import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react"; import { approveDevicePairing, @@ -61,6 +61,10 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined { const [code, setCode] = useState(""); const [name, setName] = useState(""); const [pairing, setPairing] = useState({ step: "idle" }); + /** The device an administrator asked to revoke and has not yet confirmed. */ + const [confirming, setConfirming] = useState(undefined); + const [revoking, setRevoking] = useState(false); + const dismissConfirmation = useCallback(() => setConfirming(undefined), []); const refresh = useCallback(async (): Promise => { try { @@ -106,13 +110,15 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined { }; const revoke = async (deviceId: string): Promise => { + setRevoking(true); try { await revokeHostDevice(config, deviceId); - await refresh(); } catch { // Left to the next refresh rather than asserting a failure the operator cannot act on. } await refresh(); + setRevoking(false); + setConfirming(undefined); }; return ( @@ -195,7 +201,7 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined { {device.revoked ? undefined : (
))} + + {/* Revoking strands the screen until someone re-pairs it in person, so the consequence is + spelled out before it happens — never a browser-native confirm. */} + + + + + } + onDismiss={dismissConfirmation} + open={confirming !== undefined} + title={`Revoke ${confirming?.name ?? "this display"}?`} + > +

+ The screen stops showing content within a few seconds and returns to its pairing code. + This cannot be undone: to use it again, pair it from the screen with a new code. +

+
); } diff --git a/surfaces/shell/tests/device-panel.test.tsx b/surfaces/shell/tests/device-panel.test.tsx new file mode 100644 index 0000000..5eb1b74 --- /dev/null +++ b/surfaces/shell/tests/device-panel.test.tsx @@ -0,0 +1,106 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DevicePanel } from "../src/browser/DevicePanel.js"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +const apiBaseUrl = "https://ekklesia.example.test"; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); +} + +function server() { + let revoked = false; + const revokes: string[] = []; + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/v1/devices")) { + return json({ + devices: [{ id: "device-1", name: "Foyer TV", permissions: [], revoked }], + }); + } + if (url.endsWith("/api/v1/devices/device-1/revoke")) { + revoked = true; + revokes.push(url); + return new Response(null, { status: 204 }); + } + return json({ code: "NOT_FOUND" }, 404); + }) as unknown as typeof globalThis.fetch; + return { fetch, revokes }; +} + +function query(testId: string): HTMLElement | null { + return container.querySelector(`[data-testid="${testId}"]`); +} + +async function click(testId: string): Promise { + await act(async () => { + query(testId)?.click(); + for (let index = 0; index < 5; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }); +} + +/** + * Revoking a display strands that screen until someone re-pairs it in person, so the panel explains + * the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21). + */ +describe("display device revocation", () => { + it("asks in a product dialog before revoking, and does nothing on cancel", async () => { + const platform = server(); + const confirm = vi.fn(() => true); + Object.assign(window, { confirm }); + await act(async () => { + root.render(); + }); + + await click("device-revoke-device-1"); + const dialog = container.querySelector('[role="dialog"]'); + expect(dialog?.textContent).toContain("Revoke Foyer TV?"); + expect(dialog?.textContent).toContain("pairing code"); + expect(platform.revokes).toEqual([]); + + await click("device-revoke-cancel"); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(platform.revokes).toEqual([]); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("revokes only after the destructive action is confirmed", async () => { + const platform = server(); + await act(async () => { + root.render(); + }); + + await click("device-revoke-device-1"); + await click("device-revoke-confirm"); + + expect(platform.revokes).toHaveLength(1); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(query("device-device-1")?.textContent).toContain("(revoked)"); + }); +}); -- 2.54.0 From 8f90871be048c203768676643dd344622726c280 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:45:37 -0500 Subject: [PATCH 4/7] docs(deploy): document how to set up an unattended display kiosk Stage 0 requires that kiosk setup can be reproduced from documentation, and there was none. Cover the address, a single-launcher Chromium command with a persistent profile, a systemd user unit, pairing and the fixed 180-day device credential, what the screen does through each kind of outage, the known offline cold-start limit, and why a second launcher produces duplicate windows. Co-Authored-By: Claude Opus 5.5 --- deploy/README.md | 1 + deploy/display-kiosk.md | 129 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 deploy/display-kiosk.md diff --git a/deploy/README.md b/deploy/README.md index 0ce04f1..11ec1af 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -17,6 +17,7 @@ specific to one organization. | [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. | | [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. | | [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). | +| [`display-kiosk.md`](display-kiosk.md) | Setting up an unattended display screen: launch, pairing, and recovery. | ## The reference deployment diff --git a/deploy/display-kiosk.md b/deploy/display-kiosk.md new file mode 100644 index 0000000..eb21adb --- /dev/null +++ b/deploy/display-kiosk.md @@ -0,0 +1,129 @@ +# Setting up a display kiosk + +A display is a screen — a television with a small computer, or a mini PC on a wall — that opens one +module's display page full screen and keeps it open unattended. This guide sets one up so it starts +on boot, stays paired, recovers from network and server outages on its own, and never opens a second +copy of itself. + +Pairing and display identity belong to the platform (ADR 0036). Today an administrator approves a +screen in the Dashboard sidebar's **Display devices** panel; a later release moves this to +**Settings → Displays**. Nothing below changes when that happens. + +## What the screen needs + +- Chromium or Google Chrome, current stable. The display page is tested against Chromium. +- A **dedicated, persistent browser profile directory**. The device credential is an HttpOnly cookie + in that profile; it is what keeps the screen paired across reboots. Never use a guest, incognito, or + "clear data on exit" profile, and never share the profile with a person's browsing. +- Network access to the installation's **API hostname** (the display page, its realtime stream, and + its module requests) and to the storage hostname that serves images. +- A clock that is roughly right. Playback follows the server's clock, but TLS does not tolerate a + wildly wrong one. + +## The address + +Every display-capable module has one display page on the API hostname: + +```text +https:///display/ +``` + +For Signage that is `https:///display/signage`. The Signage **Displays** tab shows the +exact address under **Copy setup address**. Open it on the screen itself; opening it on an +administrator's own computer only previews the display (and that browser appears in the roster as a +*Signed-in browser*, which can be removed once it is offline). + +## Launching the browser + +Run exactly **one** launcher, and launch the page as an app window with a fixed profile: + +```bash +chromium \ + --user-data-dir=/var/lib/display-kiosk/profile \ + --app=https:///display/signage \ + --kiosk \ + --no-first-run \ + --noerrdialogs \ + --disable-session-crashed-bubble \ + --hide-crash-restore-bubble \ + --disable-features=Translate \ + --autoplay-policy=no-user-gesture-required \ + --check-for-update-interval=31536000 +``` + +- `--app` opens the page with no tab strip and no address bar. `--kiosk` makes it full screen. +- `--user-data-dir` is the persistent profile that holds the pairing. Keep it on persistent storage + and back it up with nothing: if it is lost, the screen simply shows a new pairing code. +- Do **not** add `--restore-last-session`, and do not also start the browser from a desktop autostart + entry. Chromium allows one process per profile; a second launch hands its URL to the running + process, which opens a **second window or tab** of the display. That is the usual cause of + "duplicate" displays. One launcher, one profile, one window. + +### Start on boot and restart on exit (systemd) + +Run the browser as an unprivileged user in that user's graphical session, from a single user unit: + +```ini +# ~/.config/systemd/user/display-kiosk.service +[Unit] +Description=Display kiosk +After=graphical-session.target network-online.target +Wants=network-online.target +PartOf=graphical-session.target + +[Service] +ExecStart=/usr/bin/chromium --user-data-dir=%h/.local/share/display-kiosk --app=https:///display/signage --kiosk --no-first-run --noerrdialogs --disable-session-crashed-bubble --hide-crash-restore-bubble --disable-features=Translate --autoplay-policy=no-user-gesture-required --check-for-update-interval=31536000 +Restart=always +RestartSec=5 + +[Install] +WantedBy=graphical-session.target +``` + +```bash +systemctl --user daemon-reload +systemctl --user enable --now display-kiosk.service +loginctl enable-linger # with the desktop set to log this user in automatically +``` + +`systemctl --user stop display-kiosk` closes the browser cleanly, which is what keeps its profile — +and the pairing cookie — intact. Avoid cutting power to a running kiosk when you can: a clean shutdown +is always safer for a browser profile. + +## Pairing + +1. Start the kiosk. With no credential it shows a **six-digit pairing code**. +2. In the Dashboard, enter the code under **Display devices**, give the screen a name, and approve. +3. Within a few seconds the screen collects its credential and starts showing content. +4. In Signage → **Displays**, name the display and choose its group and audience. + +A paired screen stays paired across browser restarts and reboots. The credential lasts **180 days** +from pairing and is not renewed automatically (ADR 0036); after that the screen returns to its pairing +code and is paired again the same way. Note the pairing date of each screen. + +## What happens when things go wrong + +| Event | What the screen does | +| --- | --- | +| Network drops for seconds | Nothing visible. The display keeps running; the realtime stream reconnects. | +| Network or API down for longer | Keeps playing what it has cached, with a small corner note; recovers by itself when the server is back. No remount, no reload. | +| API restarted or upgraded | Reconnects by itself. A new module version is picked up within five minutes and swapped in. | +| Browser crashes or is restarted | systemd restarts it; the same profile is still paired. | +| Machine reboots | Starts on boot and resumes. If the server is not up yet, the screen retries on its own (every few seconds, backing off to once a minute) until it is. | +| Display revoked in the Dashboard | Stops showing content within seconds and returns to its pairing code. | +| Display moved to another group or audience | Switches content within about one heartbeat (≈12 s), no reload. | + +**Offline cold start is not supported yet.** A screen that is already showing content survives an +outage. A screen that *boots* while the server is completely unreachable can load its cached page but +cannot mount the module until the server answers, because the authorization it needs is never cached +(ADR 0043, Signage ADR 0006). It shows a short message and retries on its own until the server is +reachable — nobody needs to touch it. + +## Checklist for a new kiosk + +- [ ] One systemd unit, no desktop autostart entry, no second launcher. +- [ ] `--user-data-dir` on persistent storage, not shared, not cleared on exit. +- [ ] Automatic login of the kiosk user, and screen blanking/power saving disabled. +- [ ] Paired and named; assigned in Signage; pairing date recorded. +- [ ] Unplug the network for a minute: content keeps playing, then the note clears on reconnect. +- [ ] Reboot: the screen comes back paired without anyone touching it. -- 2.54.0 From 9331a8f7dfbf8cbb6528a8bff5ba842297b1ee5a Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:45:37 -0500 Subject: [PATCH 5/7] fix(display): retry a display that could not start, and re-resolve a running one periodically A kiosk that booted before its server, as after a power cut, stopped on 'This display could not be reached' until someone pressed Retry: with the network up, no online event ever came. It now retries on its own, from two seconds doubling to a minute. A running display also re-resolves its descriptor every five minutes in the background, so a module upgraded on the server is picked up without a reload, and the same answer disturbs nothing. Co-Authored-By: Claude Opus 5.5 --- surfaces/shell/src/display/DisplayApp.tsx | 46 +++++++++++++++- surfaces/shell/tests/display-app.test.tsx | 65 +++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/surfaces/shell/src/display/DisplayApp.tsx b/surfaces/shell/src/display/DisplayApp.tsx index dc385d6..bcf8f00 100644 --- a/surfaces/shell/src/display/DisplayApp.tsx +++ b/surfaces/shell/src/display/DisplayApp.tsx @@ -42,6 +42,11 @@ export interface DisplayAppProps { /** The shortest interval between two revalidations prompted by refused module requests. Test seam * only. */ readonly revalidationIntervalMs?: number; + /** How often a running display re-resolves its descriptor on its own. Test seam only. */ + readonly periodicRevalidationMs?: number; + /** The first automatic retry delay after a failed start; doubles to a one-minute ceiling. Test seam + * only. */ + readonly retryBaseMs?: number; } /** A Wi-Fi reassociation or a switch reboot lasts seconds and needs no comment on a lobby wall. Only @@ -52,6 +57,16 @@ export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000; * display that is merely missing one permission must not turn every refused request into two more. */ export const DISPLAY_REVALIDATION_INTERVAL_MS = 15_000; +/** A running display asks again this often, so a module upgraded on the server is picked up and a + * revocation is noticed even by a module that makes no requests of its own. */ +export const DISPLAY_PERIODIC_REVALIDATION_MS = 5 * 60_000; + +/** A display that could not start tries again on its own: from this delay, doubling to a minute. A + * kiosk that boots before its server — after a power cut, say — must come up without anyone pressing + * Retry. */ +export const DISPLAY_RETRY_BASE_MS = 2_000; +export const DISPLAY_RETRY_MAX_MS = 60_000; + /** Whether a fresh resolution describes exactly the display already mounted. Compared structurally: * the descriptor and principal are small, server-produced, and carry nothing volatile, so any * difference — a new version, a new contribution, a different principal — is a real one. */ @@ -192,8 +207,12 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { importModule, moduleId, offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS, + periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS, + retryBaseMs = DISPLAY_RETRY_BASE_MS, revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS, } = props; + /** Consecutive automatic retries since the display last started successfully. */ + const failures = useRef(0); const stateRef = useRef(state); stateRef.current = state; const revalidation = useRef<{ inFlight: AbortController | undefined; lastAt: number }>({ @@ -211,7 +230,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { * stays. A display that is not showing anything simply starts over. */ const revalidate = useCallback( - (reason: "online" | "authorization"): void => { + (reason: "online" | "authorization" | "periodic"): void => { const current = stateRef.current; if (current.status !== "ready") { if (reason === "online") { @@ -264,6 +283,31 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { const onAuthorizationResponse = useCallback(() => revalidate("authorization"), [revalidate]); + // While running: ask again now and then, without disturbing anything when the answer is the same. + useEffect(() => { + if (state.status !== "ready" || periodicRevalidationMs <= 0) { + return; + } + const timer = setInterval(() => revalidate("periodic"), periodicRevalidationMs); + return () => clearInterval(timer); + }, [periodicRevalidationMs, revalidate, state.status]); + + // Could not start: try again on its own, backing off. Pairing is not a failure — it has its own + // polling — and a ready display has nothing to retry. + useEffect(() => { + if (state.status === "ready") { + failures.current = 0; + return; + } + if (state.status !== "failed" && state.status !== "unavailable") { + return; + } + const delay = Math.min(DISPLAY_RETRY_MAX_MS, retryBaseMs * 2 ** Math.min(failures.current, 10)); + failures.current += 1; + const timer = setTimeout(() => setAttempt((value) => value + 1), delay); + return () => clearTimeout(timer); + }, [retryBaseMs, state.status]); + useEffect(() => { if (!connectivitySource) { return; diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index 9899d4b..f5e956d 100644 --- a/surfaces/shell/tests/display-app.test.tsx +++ b/surfaces/shell/tests/display-app.test.tsx @@ -1161,4 +1161,69 @@ describe("display host — continuity across reconnects and revocation", () => { }); expect(query("display-offline")?.textContent).toBe("Offline — reconnecting"); }); + + it("starts on its own when the server comes up after the screen did", async () => { + // A kiosk that boots before its server: the network is up, so no `online` event will ever come. + let up = false; + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/v1/module-displays/announcements")) { + return up ? json(descriptorPayload()) : json({ code: "UNAVAILABLE" }, 503); + } + return json({ code: "NOT_FOUND" }, 404); + }) as unknown as typeof globalThis.fetch; + const display = countingArtifact(); + await act(async () => { + root.render( + , + ); + }); + await flush(); + expect(query("display-host-error")).not.toBeNull(); + + up = true; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + await flush(); + expect(display.counts.mounts).toBe(1); + expect(query("display-host-error")).toBeNull(); + }); + + it("re-resolves a running display periodically, remounting only for a new version", async () => { + const server = changingServer(); + const display = countingArtifact(); + await act(async () => { + root.render( + , + ); + }); + await flush(); + const before = server.descriptorCalls(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + await flush(); + expect(server.descriptorCalls()).toBeGreaterThan(before); + expect(display.counts).toEqual({ mounts: 1, unmounts: 0 }); + + server.set(200, descriptorPayload("announcements", "2.0.0")); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + await flush(); + expect(display.counts.mounts).toBe(2); + }); }); -- 2.54.0 From bae4ff3c6aa82e09723e2a87faa7ab9810764895 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 22:02:35 -0500 Subject: [PATCH 6/7] test(deploy): re-pair the kiosk after the revoke scenario The revoke scenario left the kiosk revoked, so any display check after it - the post-recreation restart check in particular - failed by construction. That is why Signage could never run it. Finish the scenario the way the roadmap states the requirement: a revoked screen shows a fresh code, is paired again, resumes without a reload, and for Signage keeps its existing roster entry rather than appearing as a new screen. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/deploy-smoke-display.mjs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/lib/deploy-smoke-display.mjs b/scripts/lib/deploy-smoke-display.mjs index d155549..5a8f7fc 100644 --- a/scripts/lib/deploy-smoke-display.mjs +++ b/scripts/lib/deploy-smoke-display.mjs @@ -14,7 +14,7 @@ // stream-failure the first realtime connection is refused: is realtime re-established? // caching-probe can a display on the API origin store storage-origin images in Cache Storage? // stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline -// revoke revoke the paired kiosk while its display is open +// revoke revoke the paired kiosk while its display is open, then pair it again // // Every DevTools command and every page-side request has its own deadline, and a step has an overall // budget. A call that never answers fails the step with a line naming that exact call, because a call @@ -976,6 +976,32 @@ async function run() { result.stopped, `a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`, ); + // Revocation is recoverable: the same screen pairs again and resumes, keeping its place in the + // module's roster. It also leaves the kiosk paired for the steps that run after this one. + const rosterBefore = moduleId === "signage" ? (await signageViewers(owner.page)).length : 0; + if (await page.waitFor(find("display-pairing-code"), "a fresh pairing code after revocation")) { + const code = ( + await page.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code") + ).replace(/\D/gu, ""); + const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", { + code, + deviceName: "Repro kiosk", + }); + check(approved.status === 200, "the owner approves the revoked screen's new code"); + check( + await waitLive(page, "the re-paired display to go live"), + "a revoked screen is paired again and resumes without a reload", + ); + if (moduleId === "signage") { + const rosterAfter = (await signageViewers(owner.page)).length; + check( + rosterAfter === rosterBefore, + `re-pairing keeps the screen's existing roster entry (${rosterBefore} before, ${rosterAfter} after)`, + ); + } + } else { + check(false, "a revoked screen shows a fresh pairing code"); + } await page.close(); kiosk.close(); await finishOwner(owner); -- 2.54.0 From 5e9433d96b413117374a6132d89400867414c2c8 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 22:30:17 -0500 Subject: [PATCH 7/7] docs(deploy): describe the in-use credential renewal in the kiosk guide ADR 0056 (Zayin/Ekklesia#94) replaces the fixed 180 days from pairing with 180 days from last use, renewed while the screen is in use; the guide no longer tells operators to track pairing dates. Co-Authored-By: Claude Opus 5.5 --- deploy/display-kiosk.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/deploy/display-kiosk.md b/deploy/display-kiosk.md index eb21adb..1ecd422 100644 --- a/deploy/display-kiosk.md +++ b/deploy/display-kiosk.md @@ -97,9 +97,10 @@ is always safer for a browser profile. 3. Within a few seconds the screen collects its credential and starts showing content. 4. In Signage → **Displays**, name the display and choose its group and audience. -A paired screen stays paired across browser restarts and reboots. The credential lasts **180 days** -from pairing and is not renewed automatically (ADR 0036); after that the screen returns to its pairing -code and is paired again the same way. Note the pairing date of each screen. +A paired screen stays paired across browser restarts and reboots, and for as long as it is in use: +its credential is renewed each time it checks in, and only expires after **180 days without being +used** (ADR 0056). A screen that is switched off and stored for longer than that, or that is revoked, +returns to its pairing code and is paired again the same way. ## What happens when things go wrong @@ -124,6 +125,6 @@ reachable — nobody needs to touch it. - [ ] One systemd unit, no desktop autostart entry, no second launcher. - [ ] `--user-data-dir` on persistent storage, not shared, not cleared on exit. - [ ] Automatic login of the kiosk user, and screen blanking/power saving disabled. -- [ ] Paired and named; assigned in Signage; pairing date recorded. +- [ ] Paired and named; assigned in Signage. - [ ] Unplug the network for a minute: content keeps playing, then the note clears on reconnect. - [ ] Reboot: the screen comes back paired without anyone touching it. -- 2.54.0