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 diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 32dd36d..674d773 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -31,7 +31,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js import type { DashboardHostAssets } from "./dashboard-host-assets.js"; import { createDashboardHostRoutes } from "./dashboard-host-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js"; -import { createDeviceRoutes } from "./device-routes.js"; +import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js"; import type { DisplayHostAssets } from "./display-host-assets.js"; import { createGrantsRoutes } from "./grants-routes.js"; import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js"; @@ -407,6 +407,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono { registry: options.authorization.registry, runtime, ...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}), + ...(options.devices + ? { + renewDeviceCredential: (c: Context) => + renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true), + } + : {}), }); if (options.devices) { diff --git a/apps/api/src/device-routes.ts b/apps/api/src/device-routes.ts index ba9a6c6..de0fbbe 100644 --- a/apps/api/src/device-routes.ts +++ b/apps/api/src/device-routes.ts @@ -92,7 +92,7 @@ function readString(body: Record, key: string, max: number): st */ function writeDeviceCookie( c: Context, - options: DeviceRoutesOptions, + options: Pick, name: string, value: string, maxAgeSeconds: number, @@ -106,6 +106,27 @@ function writeDeviceCookie( }); } +/** + * Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056). + * + * The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still + * holds a working credential. Only the browser's own expiry is pushed forward, matching the + * server's sliding window. Called only after the request resolved to a live, unrevoked device. + */ +export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void { + const credential = getCookie(c, DEVICE_SESSION_COOKIE); + if (credential === undefined || credential.length === 0) { + return; + } + writeDeviceCookie( + c, + { secureCookies }, + DEVICE_SESSION_COOKIE, + credential, + Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000), + ); +} + export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { const routes = new HonoApp(); const now = options.now ?? ((): Date => new Date()); diff --git a/apps/api/src/module-display-routes.ts b/apps/api/src/module-display-routes.ts index 81cb5ea..4429e58 100644 --- a/apps/api/src/module-display-routes.ts +++ b/apps/api/src/module-display-routes.ts @@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio /** Omitted only when the registry contains no display entrypoints. Production reads the exact * surface-owned files before binding and supplies their frozen contents here. */ readonly assets?: DisplayHostAssets; + /** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the + * deployment has device support; called only for a request that resolved to a device. */ + readonly renewDeviceCredential?: (c: Context) => void; } /** A composition error, detected before any display route is registered. Carries no module id, @@ -199,6 +202,11 @@ function mountDisplay( if (!viewer) { return displayFailure(c); } + // Every screen asks for this on start, on reconnect, and periodically while running, so it + // is where an in-use credential's browser expiry is pushed forward. + if (principal.kind === "device") { + options.renewDeviceCredential?.(c); + } return c.json(await describeDisplay(options, principal, display, viewer), 200); } catch { return displayFailure(c); diff --git a/apps/api/tests/device-pairing.integration.ts b/apps/api/tests/device-pairing.integration.ts index 99f6d4b..a2bc7d1 100644 --- a/apps/api/tests/device-pairing.integration.ts +++ b/apps/api/tests/device-pairing.integration.ts @@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => { } }); + /** + * ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last + * seen. Ages are set directly in the database, exactly as months of real use would leave them. + */ + describe("credential lifetime", () => { + const ttlSeconds = 180 * 24 * 60 * 60; + const descriptor = "/api/v1/module-displays/announcements"; + + function renewal(response: Response): string | undefined { + return setCookieFor(response, "ekklesia_device_session"); + } + + it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => { + const running = await platform("renewal"); + const device = new DeviceBrowser(running.origin); + try { + await pairDevice(running, device); + const credential = device.header().replace(/^.*ekklesia_device_session=/u, ""); + // Paired 200 days ago, and seen yesterday: a screen that has simply been working. + await query( + running.url, + `update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`, + ); + + const response = await device.request(descriptor); + expect(response.status).toBe(200); + // The same credential, re-issued with a full lifetime and the same protections. + const header = renewal(response); + expect(header).toBeDefined(); + expect(header).toContain(`ekklesia_device_session=${credential};`); + expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u")); + expect(header).toMatch(/HttpOnly/u); + expect(header).toMatch(/SameSite=Strict/u); + expect(header).toMatch(/Path=\//u); + expect(header).not.toMatch(/Domain=/iu); + + // Using it moved the server's window forward too. + const [row] = await query<{ recent: boolean }>( + running.url, + `select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`, + ); + expect(row?.recent).toBe(true); + } finally { + await running.close(); + } + }); + + it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => { + const running = await platform("legacy"); + const device = new DeviceBrowser(running.origin); + try { + await pairDevice(running, device); + // A production screen paired 179 days ago that checked in this morning: under the old + // rule it had one day left. It must carry straight on. + await query( + running.url, + `update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`, + ); + expect((await device.request(descriptor)).status).toBe(200); + await query( + running.url, + `update ekklesia_core_device set created_at = now() - interval '190 days'`, + ); + expect((await device.request(descriptor)).status).toBe(200); + } finally { + await running.close(); + } + }); + + it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => { + const running = await platform("abandoned"); + const device = new DeviceBrowser(running.origin); + try { + await pairDevice(running, device); + await query( + running.url, + `update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`, + ); + const response = await device.request(descriptor); + // Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code. + expect(response.status).toBe(403); + expect(renewal(response)).toBeUndefined(); + } finally { + await running.close(); + } + }); + + it("never renews a revoked screen", async () => { + const running = await platform("revoked-renewal"); + const device = new DeviceBrowser(running.origin); + try { + await pairDevice(running, device); + const listed = (await ( + await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices") + ).json()) as { devices: Array<{ id: string }> }; + await adminRequest( + running.origin, + running.ownerCookie, + `/api/v1/devices/${listed.devices[0]?.id}/revoke`, + {}, + ); + const response = await device.request(descriptor); + expect(response.status).toBe(403); + expect(renewal(response)).toBeUndefined(); + } finally { + await running.close(); + } + }); + + it("does not hand a device cookie to a person viewing the display", async () => { + const running = await platform("human-renewal"); + try { + const response = await adminRequest(running.origin, running.ownerCookie, descriptor); + expect(response.status).toBe(200); + expect(renewal(response)).toBeUndefined(); + } finally { + await running.close(); + } + }); + }); + it("keeps a human administrator's own session unaffected by a device cookie", async () => { const running = await platform("mixed"); const device = new DeviceBrowser(running.origin); diff --git a/core/src/devices/store.ts b/core/src/devices/store.ts index 5d3ffa2..1bdf4d4 100644 --- a/core/src/devices/store.ts +++ b/core/src/devices/store.ts @@ -44,17 +44,26 @@ export class DeviceStoreError extends Error { export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000; /** - * How long a device credential is accepted before the device must pair again. + * How long a device credential may go **unused** before the device must pair again (ADR 0056). * - * A display runs unattended for months; forcing a signage player to be re-paired every fortnight - * because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen - * on a schedule. What makes a long horizon safe is that it is not the security boundary: the server - * decides on every single request whether the device is still revoked, so an administrator's revoke - * takes effect immediately regardless of how long the credential would otherwise have lived. The - * expiry exists so an abandoned, forgotten screen eventually stops being an identity at all. + * The window slides: every request from the device moves `lastSeenAt` forward, and the host + * re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an + * administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it + * on every single request. The window exists only so an abandoned, forgotten screen eventually stops + * being an identity at all: 180 days after it was last seen, its credential is dead. */ export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000; +/** When a device credential expires: the idle window, measured from the last time the device was + * seen, or from pairing if it never has been. */ +export function deviceCredentialExpiresAt(input: { + readonly createdAt: Date; + readonly lastSeenAt?: Date | null | undefined; +}): Date { + const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0); + return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS); +} + /** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling * realtime and assets would otherwise turn an operational nicety into a continuous write load. */ export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000; @@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore { } // The credential's own horizon, measured from when the device was paired. Revocation is // checked above and is what makes a long horizon acceptable. - if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) { + // Expiry is measured from last use, not from pairing, so a screen in use never ages out. + if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) { return undefined; } return Object.freeze({ 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..1ecd422 --- /dev/null +++ b/deploy/display-kiosk.md @@ -0,0 +1,130 @@ +# 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, 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 + +| 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. +- [ ] 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. diff --git a/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md b/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md index e73bf26..a5fcc6a 100644 --- a/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md +++ b/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md @@ -2,6 +2,7 @@ - Status: Accepted - Date: 2026-08-24 +- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md) - Extends: [0013](0013-core-authorization-and-owner-bootstrap.md), [0032](0032-native-module-realtime-transport.md), [0033](0033-display-kiosk-host-contract.md), diff --git a/docs/architecture/decisions/0056-device-credentials-last-while-in-use.md b/docs/architecture/decisions/0056-device-credentials-last-while-in-use.md new file mode 100644 index 0000000..0e61461 --- /dev/null +++ b/docs/architecture/decisions/0056-device-credentials-last-while-in-use.md @@ -0,0 +1,76 @@ +# 0056: A device credential lasts while the device is in use + +- Status: Accepted +- Date: 2026-10-12 +- Supersedes: the "Session lifetime" section of [0036](0036-managed-device-identity-and-pairing.md). + Everything else in ADR 0036 — the device as its own principal, pairing, revocation on every + request, re-pairing as a new identity — is unchanged. + +## Context + +ADR 0036 gave a device credential a fixed 180 days from pairing. The intent was that a screen on a +ceiling should not need re-pairing on a short cycle, and that an abandoned screen should eventually +stop being an identity. + +A fixed horizon does the second and fails the first. Every screen in the building expires on the +anniversary of its pairing whether or not it was in use the day before, and drops to a pairing code +in front of the congregation until someone with Dashboard access walks to it. For screens paired +together, that is every screen at once. Nothing about a working, unrevoked screen is less trustworthy +on day 181 than on day 179; only the calendar changed. + +## Decision + +**The 180 days are an idle window, not a lifetime.** A device credential is accepted while the +device was last seen less than 180 days ago (or, if it has never been seen, was paired less than 180 +days ago). `lastSeenAt` already moves forward on authenticated device requests (throttled to one +write per 15 minutes), so the server-side window slides with use and needs no new column or +migration. + +**The browser's copy is renewed by the host, not rotated.** The display descriptor — +`GET /api/v1/module-displays/`, which every display requests when it starts, when it +reconnects, and periodically while it runs — re-issues the device cookie that authenticated the +request with a fresh 180-day `Max-Age` and the same attributes as at pairing (`HttpOnly`, +`SameSite=Strict`, host-only, `Path=/`, `Secure` on https). The value is unchanged. + +Renewal happens only for a request that already resolved to a live, unrevoked device. A revoked, +expired, or unknown credential is anonymous, as before, and is never renewed; a person viewing a +display never receives a device cookie. + +**Revocation remains the boundary.** It is still checked on every request, and an administrator's +revoke still takes effect immediately. + +## Why not rotate + +Rotation (issue a new value, invalidate the old) would bound how long any one stolen value works and +could reveal a cloned credential. For an unattended screen it trades that for a new way to strand +the display: a renewal response lost to a network drop, a power cut, or a browser that never +persisted the new cookie leaves the screen holding a dead credential and showing a pairing code, +which is the failure this decision exists to remove. A refresh-token scheme with overlap windows +could avoid that, at the cost of real complexity in the one place mistakes are invisible until a +wall goes dark. That is not justified while revocation is immediate and a device principal can only +read the displays it was paired for. + +## Consequences + +- A screen that is used at least once every 180 days stays paired until an administrator revokes + it. An abandoned screen still stops being an identity 180 days after it was last seen. +- Existing credentials need no migration. A screen paired under the fixed rule is renewed the next + time it requests its descriptor, provided its browser still holds the cookie. +- A stolen device credential stays usable for as long as anyone keeps using it, until it is revoked. + Under ADR 0036 it was usable for at most 180 days from pairing. The exposure is bounded by what a + device may do — the display permissions of the module it was paired for, never a person's access + — and by revocation. Operators can see each device's last-seen time and revoke anything + unexpected. +- A credential is not bound to a browser or network. Like a human session cookie, whoever presents + it is the device. + +## Alternatives + +- **Keep the fixed 180 days.** Rejected: every in-use screen fails on a schedule, and screens paired + together fail together. +- **Never expire.** Rejected: an abandoned or forgotten screen would remain an identity forever, and + its credential would never stop working without someone remembering to revoke it. +- **Rotate on renewal.** Rejected for now; see above. +- **Renew on every authenticated response.** Rejected: principal resolution happens deep in shared + middleware that never writes responses. One endpoint every display already requests periodically + is enough, and keeps the behaviour in one visible place. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 18c70de..ca39271 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -44,7 +44,7 @@ ADRs capture durable choices that affect more than one package or phase. - [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted - [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted - [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted -- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted +- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted (session lifetime superseded by 0056) - [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted - [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted - [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted @@ -64,3 +64,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted - [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted - [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted +- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted 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); 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 : ( + + + } + 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/src/display/DisplayApp.tsx b/surfaces/shell/src/display/DisplayApp.tsx index e4f291f..bcf8f00 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,69 @@ 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; + /** 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 + * 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; + +/** 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. */ +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 +155,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 +201,112 @@ 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, + 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 }>({ + 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" | "periodic"): 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]); + + // 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) { @@ -142,27 +318,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 +348,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 +363,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 +392,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/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)"); + }); +}); diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index a5df29b..f5e956d 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,297 @@ 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"); + }); + + 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); + }); +});