diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 904de7d..d12cd6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,7 +67,9 @@ pnpm infrastructure:config - Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at `/display/`, never Dashboard UI with hidden chrome. Its page and descriptor require the module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain - anonymous trusted code. Public/device identity, display inventory, and variants are deferred. + anonymous trusted code. Public/device identity, display inventory, and variants are deferred. A + display-scoped service worker caches the shell and declared artifacts only, network-first, and + never a descriptor, an API response, or anything else authorization-bearing (ADR 0043). - Dashboard groups are presentation configuration, never module structure. Do not add a parent module, child module, owning module, or group-derived dependency: real module relationships are the dependency contract's job alone, and grouping must never influence discovery, dependency diff --git a/apps/api/src/display-host-assets.ts b/apps/api/src/display-host-assets.ts index b5a16f3..5371ea2 100644 --- a/apps/api/src/display-host-assets.ts +++ b/apps/api/src/display-host-assets.ts @@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises"; import { displayHostAssetUrls } from "@ekklesia/shell/display-assets"; /** - * The two immutable, platform-owned files that make up Ekklesia's display browser host. + * The three immutable, platform-owned files that make up Ekklesia's display browser host. * * They are read once, before the HTTP listener is bound, from the exact URLs exported by the * surface package. Request input is never involved in this read and the package is never exposed as @@ -14,6 +14,10 @@ export interface DisplayHostAssets { readonly html: string; readonly script: string; readonly scriptEtag: string; + /** The display's offline service worker (ADR 0043) — a third fixed, platform-owned file, served + * at a path whose directory is its scope so it controls display pages and nothing else. */ + readonly serviceWorker: string; + readonly serviceWorkerEtag: string; } /** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package @@ -36,18 +40,24 @@ export class DisplayHostAssetError extends Error { * cannot accidentally pair bytes from one build with an ETag from another. Empty output is a * failed host build rather than a valid page that can never mount anything. */ -export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets { - if (html.trim().length === 0 || script.trim().length === 0) { +export function createDisplayHostAssets( + html: string, + script: string, + serviceWorker: string, +): DisplayHostAssets { + if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) { throw new DisplayHostAssetError(); } return Object.freeze({ html, script, scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`, + serviceWorker, + serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`, }); } -/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather +/** Loads only the three surface-declared files. A missing or unreadable build fails startup rather * than leaving a display route that can authorize successfully and then return a broken shell. */ export async function loadDisplayHostAssets(): Promise { try { @@ -55,7 +65,8 @@ export async function loadDisplayHostAssets(): Promise { // rejected sibling promise is left running after this function has already failed. const html = await readFile(new URL(displayHostAssetUrls.html), "utf8"); const script = await readFile(new URL(displayHostAssetUrls.script), "utf8"); - return createDisplayHostAssets(html, script); + const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8"); + return createDisplayHostAssets(html, script, serviceWorker); } catch { throw new DisplayHostAssetError(); } diff --git a/apps/api/src/module-display-routes.ts b/apps/api/src/module-display-routes.ts index 6c1e48a..de192c8 100644 --- a/apps/api/src/module-display-routes.ts +++ b/apps/api/src/module-display-routes.ts @@ -21,6 +21,18 @@ import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js"; export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays"; export const MODULE_DISPLAY_PAGE_PREFIX = "/display"; export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js"; +/** + * Where the display's offline service worker is served (ADR 0043). + * + * The path is chosen for its **directory**: a service worker's default scope is the directory it was + * served from, so `/display/sw.js` controls display pages and nothing else. Serving it from + * `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening + * it with `Service-Worker-Allowed` would put the Dashboard under a worker it has no use for. + * + * `sw.js` cannot collide with a display page: a display page is registered at the exact literal + * `/display/` for each display-capable module, and `sw.js` is not a valid module id. + */ +export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js"; export function moduleDisplayPageUrl(moduleId: string): string { return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`; @@ -107,8 +119,11 @@ function etagMatches(headerValue: string | undefined, etag: string): boolean { * bytes with a stale validator. */ function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets { try { - const snapshot = createDisplayHostAssets(input.html, input.script); - if (snapshot.scriptEtag !== input.scriptEtag) { + const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker); + if ( + snapshot.scriptEtag !== input.scriptEtag || + snapshot.serviceWorkerEtag !== input.serviceWorkerEtag + ) { throw new ModuleDisplayConfigurationError(); } return snapshot; @@ -252,6 +267,33 @@ export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutes }); }); + /** + * The worker itself is `no-cache` with a strong validator, exactly like the host script: a browser + * re-checks it on its own schedule, and the worker deliberately never caches *itself*, which is + * how a screen avoids being trapped on an obsolete worker. + * + * Like the host script and module artifact bytes, fetching this code grants no authority. It + * caches only public code and never a permission decision, so serving it anonymously withholds + * nothing the platform was protecting. + */ + app.get(DISPLAY_SERVICE_WORKER_PATH, (c) => { + if (etagMatches(c.req.header("if-none-match"), assets.serviceWorkerEtag)) { + return new Response(null, { + headers: { "Cache-Control": "no-cache", ETag: assets.serviceWorkerEtag }, + status: 304, + }); + } + return new Response(assets.serviceWorker, { + headers: { + "Cache-Control": "no-cache", + "Content-Type": "text/javascript; charset=utf-8", + ETag: assets.serviceWorkerEtag, + "X-Content-Type-Options": "nosniff", + }, + status: 200, + }); + }); + for (const display of displays) { mountDisplay(app, { ...options, assets }, display); } diff --git a/apps/api/tests/display-host-assets.test.ts b/apps/api/tests/display-host-assets.test.ts index 9a031c8..9b8de76 100644 --- a/apps/api/tests/display-host-assets.test.ts +++ b/apps/api/tests/display-host-assets.test.ts @@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/display-assets", () => ({ displayHostAssetUrls: Object.freeze({ html: "file:///surface/browser/display/index.html", script: "file:///surface/browser/display/app.js", + serviceWorker: "file:///surface/browser/display/sw.js", }), })); @@ -20,21 +21,28 @@ import { describe("display host asset loading", () => { beforeEach(() => readFileMock.mockReset()); - it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => { + it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => { const html = '
'; const script = "export const displayHost = true;\n"; - readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script); + const serviceWorker = "export const displayWorker = true;\n"; + readFileMock + .mockResolvedValueOnce(html) + .mockResolvedValueOnce(script) + .mockResolvedValueOnce(serviceWorker); const assets = await loadDisplayHostAssets(); expect(readFileMock.mock.calls).toEqual([ [new URL("file:///surface/browser/display/index.html"), "utf8"], [new URL("file:///surface/browser/display/app.js"), "utf8"], + [new URL("file:///surface/browser/display/sw.js"), "utf8"], ]); expect(assets).toEqual({ html, script, scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`, + serviceWorker, + serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`, }); expect(Object.isFrozen(assets)).toBe(true); }); @@ -62,12 +70,15 @@ describe("display host asset loading", () => { } }); - it("rejects an empty page or script as an unavailable host build", () => { - for (const [html, script] of [ - ["", "export {};"], - ["", " \n"], + it("rejects an empty page, script, or service worker as an unavailable host build", () => { + for (const [html, script, serviceWorker] of [ + ["", "export {};", "export {};"], + ["", " \n", "export {};"], + ["", "export {};", " \n"], ] as const) { - expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError); + expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow( + DisplayHostAssetError, + ); } }); }); diff --git a/apps/api/tests/module-display-routes.test.ts b/apps/api/tests/module-display-routes.test.ts index ebee108..8ab3783 100644 --- a/apps/api/tests/module-display-routes.test.ts +++ b/apps/api/tests/module-display-routes.test.ts @@ -28,6 +28,7 @@ import { createApiApp } from "../src/app.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js"; import { DISPLAY_HOST_SCRIPT_PATH, + DISPLAY_SERVICE_WORKER_PATH, ModuleDisplayConfigurationError, moduleDisplayDescriptorUrl, moduleDisplayPageUrl, @@ -51,6 +52,7 @@ const displayPermission = "announcements.display.view"; const displayHostAssets: DisplayHostAssets = createDisplayHostAssets( `
`, "globalThis.__ekklesiaDisplayHostLoaded = true;\n", + "globalThis.__ekklesiaDisplayWorkerLoaded = true;\n", ); const temporaryDirectories: string[] = []; @@ -477,7 +479,11 @@ describe("module display host asset publication", () => { }); it("snapshots injected assets and rejects a validator that does not describe its script", async () => { - const source = createDisplayHostAssets("display host", "export const host = 1;\n"); + const source = createDisplayHostAssets( + "display host", + "export const host = 1;\n", + "export const worker = 1;\n", + ); const mutable = { ...source }; const { app } = await install({ assets: mutable, display: true }); mutable.html = "mutated secret"; @@ -498,4 +504,55 @@ describe("module display host asset publication", () => { expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404); }); + + it("serves the offline service worker from the path whose directory is its scope", async () => { + const { app } = await install({ display: true }); + + const response = await app.request(DISPLAY_SERVICE_WORKER_PATH); + + // `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing + // else. The Dashboard is never controlled by it (ADR 0043). + expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js"); + expect(response.status).toBe(200); + expect(await response.text()).toBe(displayHostAssets.serviceWorker); + expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + // `no-cache` with a strong validator, exactly like the host script: a browser re-checks the + // worker on its own schedule, which is how a screen avoids being trapped on an obsolete one. + expect(response.headers.get("cache-control")).toBe("no-cache"); + expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + it("revalidates the service worker with its own validator, never the host script's", async () => { + const { app } = await install({ display: true }); + + const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, { + headers: { "if-none-match": displayHostAssets.serviceWorkerEtag }, + }); + expect(notModified.status).toBe(304); + + const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, { + headers: { "if-none-match": displayHostAssets.scriptEtag }, + }); + expect(wrongValidator.status).toBe(200); + }); + + it("does not publish the service worker when no eligible display uses it", async () => { + const { app } = await install({ ui: true, withAssets: false }); + + expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404); + }); + + it("cannot be reached through a neighbouring or request-selected path", async () => { + const { app } = await install({ display: true }); + + for (const candidate of [ + `${DISPLAY_SERVICE_WORKER_PATH}.map`, + "/display/sw.js/", + "/sw.js", + "/assets/ekklesia-display/sw.js", + ]) { + expect((await app.request(candidate)).status, candidate).toBe(404); + } + }); }); diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index 7680e5e..69f7623 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -278,6 +278,7 @@ async function startHarness(options: HarnessOptions = {}): Promise { displayHostAssets: createDisplayHostAssets( '
', "export const displayHost = true;\n", + "export const displayWorker = true;\n", ), }), moduleLifecycle: { diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index d8e13ea..67237bc 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -240,6 +240,7 @@ async function install( displayHostAssets: createDisplayHostAssets( '
', "export {};\n", + "export {};\n", ), } : {}), diff --git a/docs/architecture/decisions/0043-display-offline-code-fallback.md b/docs/architecture/decisions/0043-display-offline-code-fallback.md new file mode 100644 index 0000000..a8ac563 --- /dev/null +++ b/docs/architecture/decisions/0043-display-offline-code-fallback.md @@ -0,0 +1,173 @@ +# 0043: The display caches its shell and declared code, and nothing else + +- Status: Accepted +- Date: 2026-08-27 +- Supersedes: none +- Related: [ADR 0025](0025-trusted-module-ui-asset-publication.md), + [ADR 0031](0031-direct-object-delivery.md), + [ADR 0032](0032-native-module-realtime-transport.md), + [ADR 0033](0033-display-kiosk-host-contract.md), + [ADR 0036](0036-managed-device-identity-and-pairing.md), + [ADR 0041](0041-module-browser-contributions.md) + +## Context + +A display is the one surface in this platform that is expected to outlive its network. It is a +television on a wall in a building whose uplink is a consumer router, nobody is standing in front of +it, and the person who could press a button is not in the building on a Tuesday. + +Today, when that network drops and the screen reboots — a power blip, a scheduled restart, a +janitor's foot — the browser shows **its own error page**. Not the display shell, not an Ekklesia +message, not a retry: a network failure with no Ekklesia in it at all, until someone notices and +does something about it. Nothing about that page recovers on its own, because there is no Ekklesia +code running on it to recover. + +The temptation is to solve this by caching more: the descriptor, the principal, the module's data, +so a screen can keep showing content through an outage. That is exactly the wrong instinct. A cached +descriptor is a cached permission decision, and a screen replaying one is a revoked device still +showing content it is no longer entitled to. + +## Decision + +Cache **the shell and the declared code**, and nothing else, in a service worker scoped to the +display. + +### What is cached + +- The display page (`/display/`). +- The fixed display host script. +- The versioned browser artifacts the display actually loaded: its module's display artifact, and + every contributed renderer it mounted (ADR 0041). + +All three are code. All three are already public bytes that confer no authority (ADR 0025, ADR 0041). +All three are addressed by a URL that carries an exact version, so a cache entry is unambiguous about +what it is. + +### What is never cached, under any condition + +The display descriptor, `/api/v1/principal`, every other platform and module API response, the +realtime stream, pairing requests and responses, and **every cross-origin request** — which is what a +signed object-delivery URL is (ADR 0031), a bearer capability with an expiry that a cache would +outlive. + +This is an **allowlist**, not a deny list. A deny list has to be updated every time the platform gains +an endpoint, and the cost of forgetting is a stored permission decision. Here the cost of forgetting +is that a new kind of code is simply not cached, which is a bug rather than a vulnerability. + +### The consequence, stated rather than papered over + +**A display cannot render module content while its network is down.** Mounting requires a descriptor; +a descriptor is authorization-bearing; it is never served from a cache. A revoked device therefore +stays revoked, and cached code can never manufacture current authorization — the two properties this +pass must not trade away. + +What the cache buys is real and worth having on its own: + +- A kiosk that reboots during an outage boots into **Ekklesia's** honest offline state instead of the + browser's error page, and keeps retrying by itself. +- The moment connectivity returns it resolves and mounts with **nothing left to download** — the + 600 KB artifact is already there. +- A momentary failure fetching an artifact, after a descriptor already resolved, falls back to the + identical bytes instead of failing the whole display. The descriptor is tiny and the artifact is + not, so this is the failure that actually happens. + +Module-owned offline content — a module persisting its own state and media so a screen can keep +showing yesterday's slides — remains the module's responsibility. This is the code foundation it +would build on. + +### Network first, always + +Every cached kind is fetched from the network first and only falls back when that fetch fails. A +kiosk therefore picks up new code the moment the server returns and can never be trapped on an +obsolete build — which is precisely the failure mode a cache-first worker produces on an unattended +screen, and the one that requires physically visiting it to fix. + +Only a complete, successful, same-origin `200` is ever stored. A `304`, a `404`, a redirect, an +opaque response, and a `503` from an API mid-restart are all things a screen must never later replay +as though they were the artifact. + +### Versioned keys and pruning + +Cache names carry a policy version; superseded generations are deleted on activation, so a policy +change cannot leave a screen serving entries from rules that no longer apply. + +Artifact URLs already carry an exact module version, so a new build lands *beside* the old one. After +storing an artifact the worker removes every entry for the same module at a different version — and +deliberately keeps every entry at the current version, because a module's display artifact and each +contributed renderer are separate URLs under one version and dropping any of them would half-cache a +working display. Another module's artifacts are never touched. + +The worker never caches **itself**, which is the other way a screen gets stuck: it is served +`no-cache` with a strong validator, exactly like the host script. + +### Scope is the directory + +The worker is served at `/display/sw.js`, chosen for its *directory*: a service worker's default +scope is where it was served from, so this one controls display pages and nothing else. The Dashboard +is never controlled by it and no Dashboard request is ever intercepted. Serving it beside the host +script under `/assets/` would scope it to a directory where no page lives; widening it with +`Service-Worker-Allowed` would put the Dashboard under a worker it has no use for. + +A controlled page's requests are intercepted wherever they point, which is how an artifact under +`/api/v1/module-ui/` is cached without widening the scope to the whole origin. + +### Reporting, not deciding + +The page gets a small browser-safe signal built from two independent sources, because either alone +would lie: `navigator.onLine` (cheap, immediate, famously optimistic — a captive portal reports +`true`) and the worker's own report that it actually served from cache (ground truth about this +application). A display renders a quiet corner indicator, never a full-page failure: a screen whose +switch rebooted must not blank a lobby wall. + +When the browser reports the network is back, the display resolves its descriptor again on its own. +Nobody presses Retry on a lobby television, and that fresh descriptor is also what stops a screen +sitting on cached code after a deployment — it names the current version, and the network-first worker +fetches it. + +Registration is an **enhancement and never a prerequisite**: a browser without service workers, a page +served over plain HTTP in development, and a policy that refuses all leave a display that works +exactly as it did before, online only. + +### Testable by construction + +Every caching decision lives in a module that touches no global: the cache storage, `fetch`, and the +way a status reaches the page all arrive as an environment. The worker file is thin wiring that +supplies the real ones. That is what lets the behaviour be exercised directly — first load, offline +reload, a missing uncached artifact, contribution fallback, version upgrade and pruning, reconnect, +and the proof that nothing authorization-bearing is ever stored — rather than through a service +worker a test runner cannot host. + +## Consequences + +- An unattended screen degrades to an Ekklesia message and recovers by itself, instead of degrading + to a browser error page and staying there. +- The platform serves a third fixed display asset, read once before the listener binds like the other + two. +- A display page is now controlled by a service worker, which is a real operational surface: an + operator debugging a screen must know a worker is in front of it. It is `no-cache`, claims clients + immediately, and caches only code, so the ways it can go wrong are bounded. +- Offline *content* is still absent, and that is the honest state of this foundation rather than an + oversight. + +## Alternatives + +- **Cache the descriptor so a display can mount offline.** Rejected outright. It is a permission + decision and a device identity; replaying one is a revoked screen still showing content. +- **Cache-first for speed.** Rejected: it is how an unattended screen ends up serving a build from + months ago with no way to notice, and fixing it means visiting the screen. +- **Precache everything at install.** Rejected: the worker would have to know which module a screen + runs and at which version — facts only the descriptor carries — and an install would fail whenever + an asset a future version needs did not exist yet. +- **Cache at the whole origin with `Service-Worker-Allowed`.** Rejected: the Dashboard gains a worker + it has no use for, for no benefit. +- **Use `navigator.onLine` alone.** Rejected: it reports `true` behind a captive portal and a dead + uplink, so a screen would insist it was fine while showing nothing. +- **Store artifacts in IndexedDB and import them from a blob URL.** Rejected: it costs a host its + `script-src` CSP, exactly as ADR 0024 rejected for the loader, and reimplements what a cache + already does correctly. + +## Deferred + +Module-owned offline content and media persistence, background sync, periodic background update, +push, a Dashboard service worker, offline pairing, precaching a display's artifact before its first +visit, and any cached response that carries identity or authorization. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index e87d890..d49fa42 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -51,3 +51,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted - [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted - [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted +- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index d453b46..fda4a95 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -119,7 +119,13 @@ These rules map platform ownership to filesystem and package boundaries. The exe receiving only the host-neutral principal and permission hints already in the UI SDK. - The display browser application is a separate surface entrypoint under `surfaces/shell`; it also statically imports no module and mounts only the exact display descriptor/artifact supplied by the - API. It contains no Dashboard chrome and does not move display rendering into the surface—the + API. It ships a third fixed asset, a service worker scoped to `/display/`, that caches **only** the + display shell and the declared browser artifacts it actually loaded + ([ADR 0043](decisions/0043-display-offline-code-fallback.md)). It never caches the descriptor, + `/api/v1/principal`, any module or platform API response, the realtime stream, a pairing exchange, + or any cross-origin request — so a revoked device stays revoked and cached code can never + manufacture current authorization. Caching is network-first, so a screen cannot be trapped on + obsolete code. It contains no Dashboard chrome and does not move display rendering into the surface—the module still owns that renderer. - Separate surfaces do not import each other's internals. - Builder code composes contracts and surface renderer identifiers; it does not import module or diff --git a/docs/module-development/README.md b/docs/module-development/README.md index cc637cc..2340e92 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -746,7 +746,7 @@ eligible, running, and declares `entrypoints.display`, Ekklesia registers exactl The page and descriptor both require the manifest's display permission through the ordinary Core authorization evaluator and an authenticated cookie session. A `public` display permission is -rejected in manifest version 6; there is no anonymous exception or device token. Artifact bytes are +rejected; there is no anonymous exception or device token. Artifact bytes are anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown, primary-only, disabled, and dependency-blocked modules have no display routes and return the normal sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart, @@ -764,8 +764,31 @@ artifact remains compatible with a future standalone host. The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API. It shows fixed loading/failure states and offers manual retry; your module owns only the rendered feature experience and cleanup inside its container. There is one display per module. Display -inventory/management, named variants, targeting, public/device identity, a standalone host, shared -CSS, Announcements migration, and Companion integration are deferred. +inventory/management, named variants, targeting, public/device identity, a standalone host, +Announcements migration, and Companion integration are deferred. + +### Your display survives a network outage — as code, not as content + +A display page is controlled by a service worker that caches **the shell and the declared code**: the +page, the host script, your display artifact, and every contributed renderer that display actually +mounted ([ADR 0043](../architecture/decisions/0043-display-offline-code-fallback.md)). Caching is +network-first, so a screen picks up a new build the moment the server returns and can never be +trapped on an obsolete one. + +What that means for you, concretely: + +- **Your artifact will be served from cache when a fetch fails.** The bytes are identical and the + loader still verifies the artifact's declared id and version, so a fallback can never mount + something the descriptor did not name. +- **Your display cannot render while the network is down.** Mounting requires the descriptor, the + descriptor is authorization-bearing, and it is never cached — so a device whose access was revoked + stays revoked, and cached code can never manufacture authorization it no longer has. The screen + shows Ekklesia's own quiet offline indicator and resolves again by itself when connectivity + returns. +- **Nothing of yours is cached but code.** Your module's API responses, your realtime stream, and + every signed object-delivery URL are deliberately never stored. If you want a screen to keep + showing content through an outage, persisting that content is *your module's* job — this is the + foundation it would build on, not a substitute for it. ### What a module UI will never own diff --git a/surfaces/shell/scripts/build-browser.mjs b/surfaces/shell/scripts/build-browser.mjs index 54e8e88..04a8cdc 100644 --- a/surfaces/shell/scripts/build-browser.mjs +++ b/surfaces/shell/scripts/build-browser.mjs @@ -44,6 +44,10 @@ const buildOptions = { entryPoints: { app: path.join(packageDirectory, "src", "browser", "main.tsx"), "display/app": path.join(packageDirectory, "src", "display", "main.tsx"), + // The display service worker (ADR 0043). A separate entry because a worker is a separate + // realm with its own globals; bundling it into the page would make it unregisterable and its + // `self` meaningless. + "display/sw": path.join(packageDirectory, "src", "display", "sw.ts"), }, format: "esm", jsx: "automatic", diff --git a/surfaces/shell/src/display-assets.ts b/surfaces/shell/src/display-assets.ts index 14ffe62..c96bf26 100644 --- a/surfaces/shell/src/display-assets.ts +++ b/surfaces/shell/src/display-assets.ts @@ -11,4 +11,7 @@ const packageRoot = new URL("../", import.meta.url); export const displayHostAssetUrls = Object.freeze({ html: new URL("dist/browser/display/index.html", packageRoot).href, script: new URL("dist/browser/display/app.js", packageRoot).href, + /** The display's offline service worker (ADR 0043). A third exact file, served at a fixed + * host-owned path whose directory is its scope — so it controls display pages and nothing else. */ + serviceWorker: new URL("dist/browser/display/sw.js", packageRoot).href, }); diff --git a/surfaces/shell/src/display/DisplayApp.tsx b/surfaces/shell/src/display/DisplayApp.tsx index ca61dcc..e4f291f 100644 --- a/surfaces/shell/src/display/DisplayApp.tsx +++ b/surfaces/shell/src/display/DisplayApp.tsx @@ -1,3 +1,4 @@ +import { designToken } from "@ekklesia/design-tokens"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import { type ReactElement, useEffect, useState } from "react"; import { @@ -7,6 +8,11 @@ import { HostProtocolError, } from "../host-protocol.js"; import { DisplayUiFrame } from "./DisplayUiFrame.js"; +import { + type DisplayConnectivity, + type DisplayConnectivitySource, + watchDisplayConnectivity, +} from "./offline-status.js"; import { PairingScreen } from "./PairingScreen.js"; type DisplayState = @@ -22,6 +28,9 @@ export interface DisplayAppProps { readonly moduleId: string; /** Test/composition seam only. Production uses the generic loader's native dynamic import. */ readonly importModule?: ModuleUiImporter; + /** 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; } const displayStyles = { @@ -34,11 +43,11 @@ const displayStyles = { }, message: { alignItems: "center", - color: "#57534e", + color: designToken("text.secondary"), display: "flex", flexDirection: "column", - fontFamily: "system-ui, sans-serif", - gap: "0.75rem", + fontFamily: designToken("typography.family-sans"), + gap: designToken("spacing.md"), height: "100%", justifyContent: "center", margin: 0, @@ -48,15 +57,47 @@ const displayStyles = { }, retry: { background: "transparent", - border: "1px solid currentColor", - borderRadius: "0.25rem", + border: `${designToken("border.width")} solid currentColor`, + borderRadius: designToken("radius.md"), color: "inherit", cursor: "pointer", font: "inherit", - padding: "0.375rem 0.75rem", + padding: `${designToken("spacing.xs")} ${designToken("spacing.md")}`, + }, + offline: { + background: designToken("status.warning-background"), + borderRadius: designToken("radius.pill"), + color: designToken("text.warning"), + fontFamily: designToken("typography.family-sans"), + fontSize: designToken("typography.size-sm"), + insetBlockStart: designToken("spacing.md"), + insetInlineEnd: designToken("spacing.md"), + padding: `${designToken("spacing.2xs")} ${designToken("spacing.md")}`, + position: "absolute", }, } as const; +/** + * A small, honest indicator: this screen is not currently in touch with Ekklesia, or is running + * from code it cached earlier. + * + * Deliberately unobtrusive and deliberately not an error. An unattended screen showing a full-page + * failure because a switch rebooted is worse than one quietly saying so in a corner, and the screen + * recovers on its own the moment connectivity returns. + */ +function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement { + 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"; + return ( +
+ {label} +
+ ); +} + function DisplayMessage(props: { readonly children: string; readonly testId: string; @@ -85,7 +126,33 @@ function DisplayMessage(props: { export function DisplayApp(props: DisplayAppProps): ReactElement { const [state, setState] = useState({ generation: 0, status: "loading" }); const [attempt, setAttempt] = useState(0); - const { config, importModule, moduleId } = props; + const [connectivity, setConnectivity] = useState({ + online: true, + usingFallback: false, + }); + const { config, connectivity: connectivitySource, importModule, moduleId } = props; + + useEffect(() => { + if (!connectivitySource) { + return; + } + const monitor = watchDisplayConnectivity(connectivitySource); + setConnectivity(monitor.current()); + const unsubscribe = monitor.subscribe((next) => { + 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). + if (next.online) { + setAttempt((current) => current + 1); + } + }); + return () => { + unsubscribe(); + monitor.close(); + }; + }, [connectivitySource]); useEffect(() => { const abort = new AbortController(); @@ -124,6 +191,9 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { return (
+ {connectivity.online && !connectivity.usingFallback ? undefined : ( + + )} {state.status === "loading" ? ( Loading display… ) : undefined} diff --git a/surfaces/shell/src/display/main.tsx b/surfaces/shell/src/display/main.tsx index 78b2703..1dfa771 100644 --- a/surfaces/shell/src/display/main.tsx +++ b/surfaces/shell/src/display/main.tsx @@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client"; import type { EkklesiaHostConfig } from "../host-protocol.js"; import { parseDisplayRoute } from "../host-protocol.js"; import { DisplayApp } from "./DisplayApp.js"; +import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js"; declare const __EKKLESIA_API_BASE_URL__: string; @@ -16,6 +17,27 @@ if (!container) { throw new Error("The Ekklesia display host requires a #ekklesia-display element."); } +/** + * Offline resilience is registered as an enhancement and never as a prerequisite (ADR 0043): a + * browser without service workers, or a policy that refuses the registration, leaves a display that + * works exactly as it did before. The promise is deliberately not awaited — a screen must render + * while its worker is still installing. + */ +void registerDisplayServiceWorker( + typeof navigator === "undefined" ? undefined : navigator.serviceWorker, +); + +/** The two real browser sources the connectivity monitor reads. Passed as data so nothing below the + * entry point touches a global. */ +const connectivity: DisplayConnectivitySource = { + addEventListener: (type, listener) => window.addEventListener(type, listener), + initialOnline: navigator.onLine, + removeEventListener: (type, listener) => window.removeEventListener(type, listener), + ...(typeof navigator === "undefined" || !navigator.serviceWorker + ? {} + : { serviceWorkerMessages: navigator.serviceWorker }), +}; + let moduleId: string | undefined; try { moduleId = parseDisplayRoute(window.location.pathname); @@ -31,7 +53,7 @@ createRoot(container).render( <> {moduleId ? ( - + ) : (
/[/...]`. Every URL under + * this prefix is compiled code whose identity the loader verifies before mounting (ADR 0025, + * ADR 0040, ADR 0041). */ +const MODULE_ARTIFACT_PREFIX = "/api/v1/module-ui/"; + +/** What this worker will store, or `undefined` for everything else — which is passed through to the + * network untouched and never enters a cache. */ +export type DisplayCacheKind = "shell" | "artifact"; + +/** Whether the shell or an artifact was served from the cache because the network failed. */ +export interface DisplayOfflineStatus { + readonly usingFallback: boolean; + /** Which kind most recently fell back, for an honest indicator rather than a generic one. */ + readonly kind?: DisplayCacheKind; +} + +export interface DisplayCacheLike { + match(request: Request | string): Promise; + put(request: Request | string, response: Response): Promise; + delete(request: Request | string): Promise; + keys(): Promise; +} + +export interface DisplayCacheStorageLike { + open(cacheName: string): Promise; + keys(): Promise; + delete(cacheName: string): Promise; +} + +export interface DisplayOfflineEnvironment { + readonly caches: DisplayCacheStorageLike; + readonly fetch: (request: Request) => Promise; + /** How a fallback reaches the page. The worker posts to its clients; a test records. */ + readonly notify?: (status: DisplayOfflineStatus) => void; +} + +/** + * Classifies a request, and refuses everything it does not explicitly recognize. + * + * An allowlist rather than a deny list, deliberately. A deny list has to be updated every time the + * platform gains an endpoint, and the failure mode of forgetting is a cached permission decision. + * Here the failure mode of forgetting is that a new kind of code is simply not cached. + */ +export function classifyDisplayRequest( + request: Request, + origin: string, +): DisplayCacheKind | undefined { + if (request.method !== "GET") { + return undefined; + } + let url: URL; + try { + url = new URL(request.url); + } catch { + return undefined; + } + // Cross-origin is never cached. A signed object-delivery URL is cross-origin and is a bearer + // capability with an expiry (ADR 0031); storing one would outlive its own lifetime. + if (url.origin !== origin) { + return undefined; + } + if (url.search !== "") { + // Nothing this worker caches is ever addressed with a query string, and a query is how live + // state is usually parameterized. Refusing them keeps the allowlist exact. + return undefined; + } + if (url.pathname === DISPLAY_SERVICE_WORKER_PATH) { + // The worker script is the browser's own business; caching it would be how a screen gets stuck + // on an obsolete worker. + return undefined; + } + if (url.pathname === DISPLAY_HOST_SCRIPT_PATH) { + return "shell"; + } + if (url.pathname.startsWith(DISPLAY_PAGE_PREFIX) && request.mode === "navigate") { + return "shell"; + } + if (url.pathname.startsWith(MODULE_ARTIFACT_PREFIX)) { + return "artifact"; + } + return undefined; +} + +function cacheNameFor(kind: DisplayCacheKind): string { + return kind === "shell" ? DISPLAY_SHELL_CACHE : DISPLAY_ARTIFACT_CACHE; +} + +/** + * The module identity a versioned artifact URL carries: `/api/v1/module-ui//` + * and anything below it. Returns `undefined` for a shape this worker does not recognize, so an + * unexpected URL is left alone rather than guessed at. + */ +function artifactIdentity(pathname: string): { module: string; version: string } | undefined { + const segments = pathname.slice(MODULE_ARTIFACT_PREFIX.length).split("/"); + const [moduleSegment, versionSegment] = segments; + if (!moduleSegment || !versionSegment) { + return undefined; + } + return { module: moduleSegment, version: versionSegment }; +} + +/** + * Removes every stored artifact for the same module at a *different* version. + * + * Version-keyed URLs mean a new build lands beside the old one rather than replacing it, so without + * this an unattended screen accumulates every version it has ever run. Pruning is deliberately + * scoped to the one module whose artifact was just stored and deliberately keeps every entry for the + * current version — a module's display artifact and each contributed renderer it mounted are + * separate URLs under one version, and dropping any of them would half-cache a working display. + */ +export async function pruneSupersededArtifacts( + cache: DisplayCacheLike, + storedUrl: string, +): Promise { + const stored = new URL(storedUrl); + const identity = artifactIdentity(stored.pathname); + if (!identity) { + return []; + } + const removed: string[] = []; + for (const request of await cache.keys()) { + const candidate = new URL(request.url); + if (candidate.origin !== stored.origin) { + continue; + } + const other = artifactIdentity(candidate.pathname); + if (!other || other.module !== identity.module || other.version === identity.version) { + continue; + } + if (await cache.delete(request)) { + removed.push(request.url); + } + } + return removed; +} + +/** Only a complete, successful, same-origin response is ever stored. A `304`, a `404`, a redirect, + * an opaque cross-origin response, and a `503` from an API mid-restart are all things a screen must + * never later replay as though they were the artifact. */ +function isStorable(response: Response): boolean { + return response.ok && response.status === 200 && response.type !== "opaque"; +} + +/** + * Network first, cache as fallback — the one strategy, for both kinds. + * + * Returns `undefined` for anything unrecognized, which the worker turns into "let the browser do + * what it would have done anyway". That is the important default: this worker's *presence* must not + * change how any other request behaves. + */ +export async function handleDisplayFetch( + request: Request, + origin: string, + environment: DisplayOfflineEnvironment, +): Promise { + const kind = classifyDisplayRequest(request, origin); + if (!kind) { + return undefined; + } + + const cache = await environment.caches.open(cacheNameFor(kind)); + try { + const response = await environment.fetch(request); + if (isStorable(response)) { + // Stored as a clone so the live response reaches the page unread — the page must never wait + // for a cache write to finish rendering. + await cache.put(request, response.clone()); + if (kind === "artifact") { + await pruneSupersededArtifacts(cache, request.url); + } + environment.notify?.({ usingFallback: false }); + } + return response; + } catch { + const cached = await cache.match(request); + if (!cached) { + // Nothing was ever stored for this exact URL — a first visit during an outage, or a version + // this screen has never run. There is no honest answer, so the network failure propagates and + // the page renders its own failure state rather than a fabricated one. + environment.notify?.({ kind, usingFallback: true }); + throw new TypeError("Ekklesia display request failed and nothing is cached for it."); + } + environment.notify?.({ kind, usingFallback: true }); + return cached; + } +} + +/** + * Deletes every cache this worker owns from a superseded generation. + * + * Run on activation. Only names carrying this worker's own prefix are considered, so a deployment + * that shares an origin with something else keeps its caches. + */ +export async function pruneSupersededCaches( + environment: DisplayOfflineEnvironment, +): Promise { + const current = new Set(DISPLAY_CACHE_NAMES); + const removed: string[] = []; + for (const name of await environment.caches.keys()) { + if (name.startsWith(DISPLAY_CACHE_PREFIX) && !current.has(name)) { + if (await environment.caches.delete(name)) { + removed.push(name); + } + } + } + return removed; +} diff --git a/surfaces/shell/src/display/offline-status.ts b/surfaces/shell/src/display/offline-status.ts new file mode 100644 index 0000000..4366a83 --- /dev/null +++ b/surfaces/shell/src/display/offline-status.ts @@ -0,0 +1,155 @@ +import type { DisplayCacheKind, DisplayOfflineStatus } from "./offline-cache.js"; + +/** + * The small browser-safe signal a display renders its offline state from (ADR 0043). + * + * Two independent sources, because they answer different questions and either one alone would lie: + * + * - `navigator.onLine` and the `online`/`offline` events say whether the browser believes it has a + * network. Cheap and immediate, and famously optimistic — a captive portal or a dead uplink behind + * a live Wi-Fi association both report `true`. + * - The service worker says whether it actually had to serve something from its cache. That is + * ground truth about this application specifically, and it is the only source that can tell a + * working network from one that merely exists. + * + * Reporting both, rather than collapsing them, is what lets a display say "offline" honestly instead + * of guessing. Neither is a security signal, and nothing here decides what a display may show — the + * descriptor is still fetched from the network on every attempt, so a device whose access was + * revoked is refused exactly as it always was. + */ +export interface DisplayConnectivity { + /** What the browser believes about the network. Optimistic; never treated as proof. */ + readonly online: boolean; + /** Whether this application has actually served the shell or an artifact from its cache. */ + readonly usingFallback: boolean; + /** Which kind most recently fell back, so an indicator can be specific rather than generic. */ + readonly fallbackKind?: DisplayCacheKind; +} + +export type DisplayConnectivityListener = (connectivity: DisplayConnectivity) => void; + +/** + * The browser capabilities this helper uses, named so a test supplies them directly. A display page + * passes nothing and gets the real ones. + */ +export interface DisplayConnectivitySource { + readonly initialOnline?: boolean; + readonly addEventListener?: (type: "online" | "offline", listener: () => void) => void; + readonly removeEventListener?: (type: "online" | "offline", listener: () => void) => void; + /** Where service-worker messages arrive. `navigator.serviceWorker` in a browser. */ + readonly serviceWorkerMessages?: { + addEventListener(type: "message", listener: (event: MessageEvent) => void): void; + removeEventListener(type: "message", listener: (event: MessageEvent) => void): void; + }; +} + +function isOfflineMessage( + data: unknown, +): data is { readonly ekklesiaDisplayOffline: DisplayOfflineStatus } { + if (typeof data !== "object" || data === null) { + return false; + } + const status = (data as { readonly ekklesiaDisplayOffline?: unknown }).ekklesiaDisplayOffline; + return typeof status === "object" && status !== null && "usingFallback" in status; +} + +export interface DisplayConnectivityMonitor { + current(): DisplayConnectivity; + subscribe(listener: DisplayConnectivityListener): () => void; + /** Detaches every listener. Idempotent. */ + close(): void; +} + +/** + * Watches connectivity and cache fallback, and tells subscribers when either changes. + * + * Deliberately reports rather than acts: it never retries, never reloads, and never decides what a + * display may render. A display that wants to retry does so through its own bootstrap, where the + * descriptor and its authorization are resolved fresh. + */ +export function watchDisplayConnectivity( + source: DisplayConnectivitySource = {}, +): DisplayConnectivityMonitor { + let connectivity: DisplayConnectivity = { + online: source.initialOnline ?? true, + usingFallback: false, + }; + const listeners = new Set(); + let closed = false; + + const publish = (next: DisplayConnectivity): void => { + if ( + next.online === connectivity.online && + next.usingFallback === connectivity.usingFallback && + next.fallbackKind === connectivity.fallbackKind + ) { + return; + } + connectivity = next; + for (const listener of listeners) { + listener(connectivity); + } + }; + + const onOnline = (): void => publish({ ...connectivity, online: true }); + const onOffline = (): void => publish({ ...connectivity, online: false }); + const onMessage = (event: MessageEvent): void => { + if (!isOfflineMessage(event.data)) { + return; + } + const status = event.data.ekklesiaDisplayOffline; + publish({ + online: connectivity.online, + usingFallback: status.usingFallback, + ...(status.usingFallback && status.kind ? { fallbackKind: status.kind } : {}), + }); + }; + + source.addEventListener?.("online", onOnline); + source.addEventListener?.("offline", onOffline); + source.serviceWorkerMessages?.addEventListener("message", onMessage); + + return { + close(): void { + if (closed) { + return; + } + closed = true; + listeners.clear(); + source.removeEventListener?.("online", onOnline); + source.removeEventListener?.("offline", onOffline); + source.serviceWorkerMessages?.removeEventListener("message", onMessage); + }, + current: () => connectivity, + subscribe(listener: DisplayConnectivityListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +/** Where the display service worker is served from — the same fixed, platform-owned path the API + * publishes it at. Its directory is its scope, so it controls display pages and nothing else. */ +export const DISPLAY_SERVICE_WORKER_URL = "/display/sw.js"; + +/** + * Registers the display service worker, and never lets failing to do so break the display. + * + * Offline resilience is an enhancement: a browser without service workers, a page served over plain + * HTTP during development, and a registration a policy refused all leave a display that works + * exactly as it did before — online only. Treating any of them as fatal would trade a real + * capability for a hypothetical one. + */ +export async function registerDisplayServiceWorker( + container: ServiceWorkerContainer | undefined, +): Promise { + if (!container) { + return false; + } + try { + await container.register(DISPLAY_SERVICE_WORKER_URL, { type: "module" }); + return true; + } catch { + return false; + } +} diff --git a/surfaces/shell/src/display/sw.ts b/surfaces/shell/src/display/sw.ts new file mode 100644 index 0000000..364ee27 --- /dev/null +++ b/surfaces/shell/src/display/sw.ts @@ -0,0 +1,76 @@ +/// +import { + DISPLAY_CACHE_NAMES, + type DisplayOfflineEnvironment, + type DisplayOfflineStatus, + handleDisplayFetch, + pruneSupersededCaches, +} from "./offline-cache.js"; + +/** + * The Ekklesia display's service worker: thin wiring, and nothing else. + * + * Every decision lives in `offline-cache.ts`, which touches no global and is therefore exercised + * directly by tests. This file exists to hand that logic the real `caches`, the real `fetch`, and a + * way to reach the page — the three things a test runner cannot host a real worker to provide. + * + * Scope is `/display/`, because this script is served from `/display/sw.js`. It therefore controls + * display pages only: the Dashboard is never controlled by it, and no Dashboard request is ever + * intercepted. A controlled page's requests are intercepted wherever they point, which is how an + * artifact under `/api/v1/module-ui/` is cached without widening the worker's scope to the whole + * origin. + */ + +declare const self: ServiceWorkerGlobalScope; + +function broadcast(status: DisplayOfflineStatus): void { + void self.clients.matchAll({ type: "window" }).then((clients) => { + for (const client of clients) { + client.postMessage({ ekklesiaDisplayOffline: status }); + } + }); +} + +const environment: DisplayOfflineEnvironment = { + caches: self.caches as unknown as DisplayOfflineEnvironment["caches"], + fetch: (request) => fetch(request), + notify: broadcast, +}; + +self.addEventListener("install", () => { + // Nothing is precached. A display's shell and artifacts are stored the first time they are + // genuinely fetched, so this worker never downloads code a screen has not been told to run, and an + // install can never fail because an asset a future version needs did not exist yet. + void self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + await pruneSupersededCaches(environment); + // Claim immediately so the first load after an update is already controlled, rather than + // leaving an unattended screen one reload behind its own worker. + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener("fetch", (event) => { + const handled = handleDisplayFetch(event.request, self.location.origin, environment); + event.respondWith( + handled.then( + (response) => + // `undefined` means this worker recognized nothing and must not change what the browser would + // have done. Re-issuing the request is the only way to say that from inside `respondWith`. + response ?? fetch(event.request), + ), + ); +}); + +self.addEventListener("message", (event) => { + if ((event.data as { readonly ekklesiaDisplayQuery?: unknown } | null)?.ekklesiaDisplayQuery) { + (event.source as Client | null)?.postMessage({ + ekklesiaDisplayCaches: DISPLAY_CACHE_NAMES, + }); + } +}); diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index 81311ea..8e4882e 100644 --- a/surfaces/shell/tests/display-app.test.tsx +++ b/surfaces/shell/tests/display-app.test.tsx @@ -9,6 +9,7 @@ import type { HostEventSourceFactory, HostEventSourceOptions, } from "../src/host-protocol.js"; +import type { DisplayConnectivitySource } from "../src/display/offline-status.js"; import { deepQuery, isolatedMountTarget, isolatedText } from "./support/isolated-dom.js"; declare global { @@ -177,11 +178,13 @@ async function render( config: EkklesiaHostConfig, importModule?: (url: string) => Promise, moduleId = "announcements", + connectivity?: DisplayConnectivitySource, ): Promise { await act(async () => { root.render( , @@ -753,3 +756,170 @@ describe("display host — independent instances and stale work", () => { expect(staleUnmounts).toEqual([staleContainer]); }); }); + +/** + * How an unattended screen behaves when its network comes and goes (ADR 0043). + * + * The caching itself is proven directly against the worker's logic in `display-offline-cache.test.ts`. + * What is proven here is the *page's* half: it says so honestly, it recovers without anyone touching + * it, and it never keeps showing content a revoked device is no longer entitled to. + */ +describe("display host — offline behaviour", () => { + /** A controllable stand-in for the browser's own connectivity events. */ + function connectivitySource(initialOnline: boolean) { + const listeners = new Map void>>(); + const workerListeners = new Set<(event: MessageEvent) => void>(); + return { + source: { + addEventListener(type: "online" | "offline", listener: () => void) { + const existing = listeners.get(type) ?? new Set(); + existing.add(listener); + listeners.set(type, existing); + }, + initialOnline, + removeEventListener(type: "online" | "offline", listener: () => void) { + listeners.get(type)?.delete(listener); + }, + serviceWorkerMessages: { + addEventListener(_type: "message", listener: (event: MessageEvent) => void) { + workerListeners.add(listener); + }, + removeEventListener(_type: "message", listener: (event: MessageEvent) => void) { + workerListeners.delete(listener); + }, + }, + } satisfies DisplayConnectivitySource, + async emit(type: "online" | "offline") { + await act(async () => { + for (const listener of listeners.get(type) ?? []) { + listener(); + } + }); + }, + async emitFallback(kind: "shell" | "artifact") { + await act(async () => { + for (const listener of workerListeners) { + listener({ + data: { ekklesiaDisplayOffline: { kind, usingFallback: true } }, + } as MessageEvent); + } + }); + }, + }; + } + + it("shows no indicator at all while the display is genuinely online", async () => { + const server = platform(); + const connectivity = connectivitySource(true); + + await render( + { apiBaseUrl, fetch: server.fetch }, + async () => artifact(), + "announcements", + connectivity.source, + ); + + expect(query("display-offline")).toBeNull(); + }); + + it("says so, quietly and honestly, when the browser goes offline", async () => { + const server = platform(); + const connectivity = connectivitySource(true); + await render( + { apiBaseUrl, fetch: server.fetch }, + async () => artifact(), + "announcements", + connectivity.source, + ); + + await connectivity.emit("offline"); + + // A corner indicator, not a full-page failure: a screen whose switch rebooted must not blank a + // lobby wall. + const indicator = query("display-offline"); + expect(indicator?.getAttribute("role")).toBe("status"); + expect(indicator?.textContent).toContain("Offline"); + expect(query("display-module-container")).not.toBeNull(); + }); + + it("reports a cache fallback even while the browser still claims to be online", async () => { + const server = platform(); + const connectivity = connectivitySource(true); + await render( + { apiBaseUrl, fetch: server.fetch }, + async () => artifact(), + "announcements", + connectivity.source, + ); + + await connectivity.emitFallback("artifact"); + + // A captive portal reports `online: true`. Only the worker knows this application actually + // served code from its cache. + expect(query("display-offline")?.textContent).toContain("last downloaded display code"); + }); + + it("resolves again on its own when the network returns — nobody presses Retry on a lobby TV", async () => { + const server = platform(); + const connectivity = connectivitySource(false); + await render( + { apiBaseUrl, fetch: server.fetch }, + async () => artifact(), + "announcements", + connectivity.source, + ); + const descriptorCalls = () => + server.calls.filter((call) => call.url.endsWith("/api/v1/module-displays/announcements")) + .length; + const before = descriptorCalls(); + + await connectivity.emit("online"); + + // A fresh descriptor is exactly what stops a screen sitting on cached code after the server + // returns: it names the current version, and the network-first worker fetches it. + expect(descriptorCalls()).toBeGreaterThan(before); + }); + + it("drops a revoked device back to pairing the moment connectivity returns", async () => { + // The descriptor is never cached, so a device whose access was revoked while it was offline + // cannot keep showing content: the first successful resolution after reconnecting is the + // server's refusal. + let revoked = false; + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/v1/principal")) { + return json({ kind: "authenticated", userId: "user-1" }); + } + if (url.endsWith("/api/v1/module-displays/announcements")) { + return revoked + ? json({ code: "AUTHORIZATION_DENIED" }, 403) + : json(descriptorPayload(), 200); + } + 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" }); + } + return json({ code: "NOT_FOUND" }, 404); + }) as unknown as typeof globalThis.fetch; + const connectivity = connectivitySource(true); + await render( + { apiBaseUrl, fetch }, + async () => artifact(), + "announcements", + connectivity.source, + ); + expect(query("display-module-container")).not.toBeNull(); + + revoked = true; + await connectivity.emit("offline"); + await connectivity.emit("online"); + + expect(query("display-module-container")).toBeNull(); + expect(query("display-pairing")).not.toBeNull(); + }); +}); diff --git a/surfaces/shell/tests/display-assets.test.ts b/surfaces/shell/tests/display-assets.test.ts index c46dd7b..4952b9a 100644 --- a/surfaces/shell/tests/display-assets.test.ts +++ b/surfaces/shell/tests/display-assets.test.ts @@ -8,14 +8,16 @@ import { displayHostAssetUrls } from "../src/display-assets.js"; const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); describe("display host asset locator", () => { - it("exports only frozen immutable href strings for the two exact built files", () => { + it("exports only frozen immutable href strings for the three exact built files", () => { expect(Object.isFrozen(displayHostAssetUrls)).toBe(true); expect(displayHostAssetUrls).toEqual({ html: new URL("dist/browser/display/index.html", `file://${packageDirectory}/`).href, script: new URL("dist/browser/display/app.js", `file://${packageDirectory}/`).href, + serviceWorker: new URL("dist/browser/display/sw.js", `file://${packageDirectory}/`).href, }); expect(typeof displayHostAssetUrls.html).toBe("string"); expect(typeof displayHostAssetUrls.script).toBe("string"); + expect(typeof displayHostAssetUrls.serviceWorker).toBe("string"); }); it("publishes the locator through a narrow package subpath, not the shell root barrel", async () => { diff --git a/surfaces/shell/tests/display-offline-cache.test.ts b/surfaces/shell/tests/display-offline-cache.test.ts new file mode 100644 index 0000000..32e0a80 --- /dev/null +++ b/surfaces/shell/tests/display-offline-cache.test.ts @@ -0,0 +1,401 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + classifyDisplayRequest, + DISPLAY_ARTIFACT_CACHE, + DISPLAY_SHELL_CACHE, + type DisplayCacheLike, + type DisplayCacheStorageLike, + type DisplayOfflineEnvironment, + type DisplayOfflineStatus, + handleDisplayFetch, + pruneSupersededArtifacts, + pruneSupersededCaches, +} from "../src/display/offline-cache.js"; + +/** + * The display's offline behaviour (ADR 0043), tested against the real caching logic rather than + * against a service worker a test runner cannot host. + * + * That is the whole reason `offline-cache.ts` touches no global: every capability arrives as an + * environment, so a fake cache and a controllable `fetch` exercise the exact code the worker runs. + * `sw.ts` is the thin wiring that supplies the real ones. + */ + +const origin = "https://ekklesia.example.test"; + +/** A faithful enough `Cache`: keyed by URL, storing whole responses, with real `keys()` ordering. */ +function fakeCache(): DisplayCacheLike & { readonly entries: Map } { + const entries = new Map(); + return { + entries, + async delete(request) { + return entries.delete(typeof request === "string" ? request : request.url); + }, + async keys() { + return [...entries.keys()].map((url) => new Request(url)); + }, + async match(request) { + return entries.get(typeof request === "string" ? request : request.url); + }, + async put(request, response) { + entries.set(typeof request === "string" ? request : request.url, response); + }, + }; +} + +interface Harness { + readonly environment: DisplayOfflineEnvironment; + readonly caches: Map>; + readonly statuses: DisplayOfflineStatus[]; + online: boolean; + /** Responses the "server" will give, by URL. Absent means a 404. */ + readonly served: Map Response>; + readonly requested: string[]; +} + +function harness(): Harness { + const caches = new Map>(); + const statuses: DisplayOfflineStatus[] = []; + const served = new Map Response>(); + const requested: string[] = []; + const state: Harness = { + caches, + environment: { + caches: { + async delete(name) { + return caches.delete(name); + }, + async keys() { + return [...caches.keys()]; + }, + async open(name) { + const existing = caches.get(name) ?? fakeCache(); + caches.set(name, existing); + return existing; + }, + } satisfies DisplayCacheStorageLike, + async fetch(request) { + requested.push(request.url); + if (!state.online) { + throw new TypeError("Failed to fetch"); + } + const responder = served.get(request.url); + return responder ? responder() : new Response("missing", { status: 404 }); + }, + notify: (status) => statuses.push(status), + }, + online: true, + requested, + served, + statuses, + }; + return state; +} + +function code(body: string): Response { + return new Response(body, { + headers: { "content-type": "text/javascript; charset=utf-8" }, + status: 200, + }); +} + +const page = `${origin}/display/signage`; +const script = `${origin}/assets/ekklesia-display/app.js`; +const artifact = (version: string) => `${origin}/api/v1/module-ui/signage/${version}/display`; +const contribution = (version: string) => + `${origin}/api/v1/module-ui/announcements/${version}/contributions/announcements.signage-slide`; + +/** + * A navigation request, as a service worker actually receives one. + * + * `mode: "navigate"` cannot be passed to the `Request` constructor — the spec forbids it, precisely + * so page code cannot forge a navigation — so the property is defined on the instance instead. That + * is what a browser hands `event.request` for a top-level load. + */ +function navigation(url: string): Request { + const request = new Request(url); + Object.defineProperty(request, "mode", { value: "navigate" }); + return request; +} + +let live: Harness; + +beforeEach(() => { + live = harness(); + live.served.set(page, () => new Response("display", { status: 200 })); + live.served.set(script, () => code("export const host = 1;")); + live.served.set(artifact("1.0.0"), () => code("export default { version: '1.0.0' };")); + live.served.set(contribution("2.0.0"), () => code("export default { slide: true };")); +}); + +async function visit(url: string, request = new Request(url)): Promise { + return handleDisplayFetch(request, origin, live.environment); +} + +describe("what the display worker will and will not cache", () => { + it.each([ + ["the display page", page, "shell"], + ["the fixed host script", script, "shell"], + ["a module display artifact", artifact("1.0.0"), "artifact"], + ["a contributed renderer", contribution("2.0.0"), "artifact"], + ])("caches %s", (_label, url, kind) => { + const request = kind === "shell" && url === page ? navigation(url) : new Request(url); + + expect(classifyDisplayRequest(request, origin)).toBe(kind); + }); + + it.each([ + ["the display descriptor", `${origin}/api/v1/module-displays/signage`], + ["the principal endpoint", `${origin}/api/v1/principal`], + ["a module API call", `${origin}/api/v1/modules/signage/slides`], + ["a realtime stream", `${origin}/api/v1/module-realtime/signage/state`], + ["a pairing request", `${origin}/api/v1/devices/pairing`], + ["an auth route", `${origin}/api/auth/sign-in/email`], + ["the Dashboard page", `${origin}/`], + ["the worker itself", `${origin}/display/sw.js`], + ["a signed object URL", "https://objects.example.test/bucket/slide.jpg?X-Amz-Signature=opaque"], + ["any other origin", "https://evil.example.test/api/v1/module-ui/signage/1.0.0/display"], + ])("never caches %s", (_label, url) => { + // An allowlist, so a platform endpoint added later is *not* cached by default rather than + // silently becoming a stored permission decision. + expect(classifyDisplayRequest(new Request(url), origin)).toBeUndefined(); + }); + + it("never caches a non-GET request or a query-bearing URL", () => { + expect( + classifyDisplayRequest(new Request(artifact("1.0.0"), { method: "POST" }), origin), + ).toBeUndefined(); + expect(classifyDisplayRequest(new Request(`${artifact("1.0.0")}?t=1`), origin)).toBeUndefined(); + }); + + it("passes an unrecognized request through untouched, storing nothing", async () => { + const descriptor = `${origin}/api/v1/module-displays/signage`; + live.served.set(descriptor, () => Response.json({ module: {} })); + + const handled = await visit(descriptor); + + // `undefined` means "do what the browser would have done anyway" — the worker's presence must + // not change how any other request behaves. + expect(handled).toBeUndefined(); + expect(live.requested).toEqual([]); + expect(live.caches.size).toBe(0); + }); +}); + +describe("first online load", () => { + it("serves everything from the network and stores the shell and artifacts", async () => { + await visit(page, navigation(page)); + await visit(script); + await visit(artifact("1.0.0")); + await visit(contribution("2.0.0")); + + expect(live.requested).toEqual([page, script, artifact("1.0.0"), contribution("2.0.0")]); + expect([...(live.caches.get(DISPLAY_SHELL_CACHE)?.entries.keys() ?? [])]).toEqual([ + page, + script, + ]); + expect([...(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.keys() ?? [])]).toEqual([ + artifact("1.0.0"), + contribution("2.0.0"), + ]); + expect(live.statuses.every((status) => !status.usingFallback)).toBe(true); + }); + + it("stores nothing for a failed, redirected, or not-modified response", async () => { + for (const [url, response] of [ + [artifact("1.0.0"), new Response(null, { status: 304 })], + [contribution("2.0.0"), new Response("nope", { status: 404 })], + [script, new Response("gone", { status: 503 })], + ] as const) { + live.served.set(url, () => response.clone()); + await visit(url); + } + + // A screen must never later replay an API restart's 503 as though it were the artifact. + expect(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.size ?? 0).toBe(0); + expect(live.caches.get(DISPLAY_SHELL_CACHE)?.entries.size ?? 0).toBe(0); + }); +}); + +describe("offline reload after one successful visit", () => { + it("serves the shell from cache and reports the fallback", async () => { + await visit(page, navigation(page)); + await visit(script); + live.online = false; + live.statuses.length = 0; + + const reloadedPage = await visit(page, navigation(page)); + const reloadedScript = await visit(script); + + expect(await reloadedPage?.text()).toBe("display"); + expect(await reloadedScript?.text()).toBe("export const host = 1;"); + // A kiosk that reboots during an outage boots into Ekklesia's own shell rather than the + // browser's error page — which is the entire point of this phase. + expect(live.statuses).toEqual([ + { kind: "shell", usingFallback: true }, + { kind: "shell", usingFallback: true }, + ]); + }); + + it("falls back to the last validated artifact and contributed renderer", async () => { + await visit(artifact("1.0.0")); + await visit(contribution("2.0.0")); + live.online = false; + + expect(await (await visit(artifact("1.0.0")))?.text()).toBe( + "export default { version: '1.0.0' };", + ); + expect(await (await visit(contribution("2.0.0")))?.text()).toBe( + "export default { slide: true };", + ); + expect(live.statuses.filter((status) => status.usingFallback)).toHaveLength(2); + }); + + it("fails honestly when nothing was ever cached for that exact URL", async () => { + live.online = false; + + // A first visit during an outage, or a version this screen has never run. There is no honest + // answer, so the failure propagates and the page renders its own state rather than a fabricated + // one. + await expect(visit(artifact("9.9.9"))).rejects.toBeInstanceOf(TypeError); + expect(live.statuses).toEqual([{ kind: "artifact", usingFallback: true }]); + }); + + it("does not fall back across versions: a cached 1.0.0 never answers for 1.1.0", async () => { + await visit(artifact("1.0.0")); + live.online = false; + + await expect(visit(artifact("1.1.0"))).rejects.toBeInstanceOf(TypeError); + }); +}); + +describe("version upgrade and pruning", () => { + it("keeps only the current version of a module's artifacts", async () => { + await visit(artifact("1.0.0")); + live.served.set(artifact("1.1.0"), () => code("export default { version: '1.1.0' };")); + + await visit(artifact("1.1.0")); + + // Version-keyed URLs mean a new build lands beside the old one; without pruning an unattended + // screen accumulates every version it has ever run. + expect([...(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.keys() ?? [])]).toEqual([ + artifact("1.1.0"), + ]); + }); + + it("keeps every artifact belonging to the version it just stored", async () => { + const primary = `${origin}/api/v1/module-ui/signage/1.0.0`; + live.served.set(primary, () => code("export default { primary: true };")); + await visit(artifact("1.0.0")); + await visit(primary); + + // A module's display artifact and its other artifacts share one version and are separate URLs; + // dropping either would half-cache a working display. + expect(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.size).toBe(2); + }); + + it("never prunes another module's artifacts", async () => { + await visit(artifact("1.0.0")); + await visit(contribution("2.0.0")); + live.served.set(artifact("1.1.0"), () => code("export default { version: '1.1.0' };")); + + await visit(artifact("1.1.0")); + + expect([...(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.keys() ?? [])]).toEqual([ + contribution("2.0.0"), + artifact("1.1.0"), + ]); + }); + + it("leaves an unrecognized artifact URL shape alone rather than guessing", async () => { + const cache = fakeCache(); + await cache.put(`${origin}/api/v1/module-ui/signage`, code("odd")); + + expect(await pruneSupersededArtifacts(cache, `${origin}/api/v1/module-ui/signage`)).toEqual([]); + expect(cache.entries.size).toBe(1); + }); + + it("deletes superseded cache generations and keeps unrelated ones", async () => { + await live.environment.caches.open("ekklesia-display-shell-v0"); + await live.environment.caches.open("some-other-application-cache"); + await live.environment.caches.open(DISPLAY_SHELL_CACHE); + + const removed = await pruneSupersededCaches(live.environment); + + expect(removed).toEqual(["ekklesia-display-shell-v0"]); + expect([...live.caches.keys()]).toEqual(["some-other-application-cache", DISPLAY_SHELL_CACHE]); + }); +}); + +describe("reconnecting to current server code", () => { + it("prefers the network on every request, so a screen is never trapped on obsolete code", async () => { + await visit(artifact("1.0.0")); + live.served.set(artifact("1.0.0"), () => code("export default { rebuilt: true };")); + + const afterRebuild = await visit(artifact("1.0.0")); + + // Network first, always: the newer bytes win, and they replace the stored copy. + expect(await afterRebuild?.text()).toBe("export default { rebuilt: true };"); + expect( + await live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.get(artifact("1.0.0"))?.text(), + ).toBe("export default { rebuilt: true };"); + }); + + it("reports that it is no longer falling back once the network returns", async () => { + await visit(script); + live.online = false; + await visit(script); + live.online = true; + live.statuses.length = 0; + + await visit(script); + + expect(live.statuses).toEqual([{ usingFallback: false }]); + }); +}); + +describe("what the cache can never do", () => { + it("cannot answer for the descriptor, so a revoked device stays revoked when online", async () => { + const descriptor = `${origin}/api/v1/module-displays/signage`; + live.served.set(descriptor, () => Response.json({ module: {} }, { status: 200 })); + await visit(descriptor); + + // It was never stored while online... + expect(live.caches.size).toBe(0); + + // ...so once the server revokes the device, the refusal is what the display sees. There is no + // cached copy of the previous success anywhere to fall back to. + live.served.set(descriptor, () => + Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }), + ); + expect(await visit(descriptor)).toBeUndefined(); + }); + + it("stores no credential, permission decision, or live state even after many visits", async () => { + for (const url of [ + `${origin}/api/v1/principal`, + `${origin}/api/v1/module-displays/signage`, + `${origin}/api/v1/modules/signage/slides`, + `${origin}/api/v1/module-realtime/signage/state`, + `${origin}/api/auth/sign-out`, + ]) { + live.served.set(url, () => Response.json({ secret: "do-not-store" })); + await visit(url); + await visit(url); + } + + const stored = [...live.caches.values()].flatMap((cache) => [...cache.entries.keys()]); + expect(stored).toEqual([]); + }); + + it("mounts nothing on its own: it only ever answers a request the page made", async () => { + // The worker has no notion of a display, a module, or a version — it classifies URLs. Every + // decision about *what* to mount stays with the page, which resolves a fresh descriptor first. + const fetchSpy = vi.fn(live.environment.fetch); + const environment: DisplayOfflineEnvironment = { ...live.environment, fetch: fetchSpy }; + + await pruneSupersededCaches(environment); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/surfaces/shell/tests/display-offline-status.test.ts b/surfaces/shell/tests/display-offline-status.test.ts new file mode 100644 index 0000000..50e3db0 --- /dev/null +++ b/surfaces/shell/tests/display-offline-status.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type DisplayConnectivity, + type DisplayConnectivitySource, + DISPLAY_SERVICE_WORKER_URL, + registerDisplayServiceWorker, + watchDisplayConnectivity, +} from "../src/display/offline-status.js"; + +/** + * The display's connectivity signal (ADR 0043): what it reports, and what it deliberately refuses to + * decide. + */ + +interface FakeSource extends DisplayConnectivitySource { + emitOnline(): void; + emitOffline(): void; + emitWorkerMessage(data: unknown): void; + readonly attached: () => number; +} + +function fakeSource(initialOnline = true): FakeSource { + const windowListeners = new Map void>>(); + const workerListeners = new Set<(event: MessageEvent) => void>(); + return { + addEventListener(type, listener) { + const existing = windowListeners.get(type) ?? new Set(); + existing.add(listener); + windowListeners.set(type, existing); + }, + attached: () => + [...windowListeners.values()].reduce((total, set) => total + set.size, 0) + + workerListeners.size, + emitOffline() { + for (const listener of windowListeners.get("offline") ?? []) { + listener(); + } + }, + emitOnline() { + for (const listener of windowListeners.get("online") ?? []) { + listener(); + } + }, + emitWorkerMessage(data: unknown) { + for (const listener of workerListeners) { + listener({ data } as MessageEvent); + } + }, + initialOnline, + removeEventListener(type, listener) { + windowListeners.get(type)?.delete(listener); + }, + serviceWorkerMessages: { + addEventListener(_type, listener) { + workerListeners.add(listener); + }, + removeEventListener(_type, listener) { + workerListeners.delete(listener); + }, + }, + }; +} + +describe("display connectivity monitor", () => { + it("starts from what the browser believes and reports nothing else", () => { + const monitor = watchDisplayConnectivity(fakeSource(false)); + + expect(monitor.current()).toEqual({ online: false, usingFallback: false }); + monitor.close(); + }); + + it("reports the browser's online and offline transitions", () => { + const source = fakeSource(true); + const monitor = watchDisplayConnectivity(source); + const seen: DisplayConnectivity[] = []; + monitor.subscribe((next) => seen.push(next)); + + source.emitOffline(); + source.emitOnline(); + + expect(seen).toEqual([ + { online: false, usingFallback: false }, + { online: true, usingFallback: false }, + ]); + monitor.close(); + }); + + it("reports a cache fallback the worker actually performed, with its kind", () => { + const source = fakeSource(true); + const monitor = watchDisplayConnectivity(source); + const seen: DisplayConnectivity[] = []; + monitor.subscribe((next) => seen.push(next)); + + // The browser still claims to be online — a captive portal, or a dead uplink behind a live + // Wi-Fi association. Only the worker knows this application actually fell back. + source.emitWorkerMessage({ ekklesiaDisplayOffline: { kind: "artifact", usingFallback: true } }); + + expect(seen).toEqual([{ fallbackKind: "artifact", online: true, usingFallback: true }]); + monitor.close(); + }); + + it("clears the fallback flag once the worker serves from the network again", () => { + const source = fakeSource(true); + const monitor = watchDisplayConnectivity(source); + source.emitWorkerMessage({ ekklesiaDisplayOffline: { kind: "shell", usingFallback: true } }); + + source.emitWorkerMessage({ ekklesiaDisplayOffline: { usingFallback: false } }); + + expect(monitor.current()).toEqual({ online: true, usingFallback: false }); + monitor.close(); + }); + + it("ignores a message that is not the worker's own status", () => { + const source = fakeSource(true); + const monitor = watchDisplayConnectivity(source); + const listener = vi.fn(); + monitor.subscribe(listener); + + for (const data of [null, "offline", { other: true }, { ekklesiaDisplayOffline: "yes" }, 7]) { + source.emitWorkerMessage(data); + } + + expect(listener).not.toHaveBeenCalled(); + monitor.close(); + }); + + it("publishes only genuine changes, so an unattended screen is not re-rendered on every event", () => { + const source = fakeSource(true); + const monitor = watchDisplayConnectivity(source); + const listener = vi.fn(); + monitor.subscribe(listener); + + source.emitOnline(); + source.emitOnline(); + source.emitOffline(); + source.emitOffline(); + + expect(listener).toHaveBeenCalledTimes(1); + monitor.close(); + }); + + it("detaches everything on close, idempotently", () => { + const source = fakeSource(true); + const monitor = watchDisplayConnectivity(source); + monitor.subscribe(() => undefined); + expect(source.attached()).toBeGreaterThan(0); + + monitor.close(); + monitor.close(); + + expect(source.attached()).toBe(0); + }); + + it("works with no sources at all, reporting a display that simply never knows", () => { + const monitor = watchDisplayConnectivity(); + + expect(monitor.current()).toEqual({ online: true, usingFallback: false }); + monitor.close(); + }); +}); + +describe("registering the display service worker", () => { + it("registers the one fixed platform path as a module worker", async () => { + const register = vi.fn().mockResolvedValue({}); + + const registered = await registerDisplayServiceWorker({ + register, + } as unknown as ServiceWorkerContainer); + + expect(registered).toBe(true); + expect(register).toHaveBeenCalledWith(DISPLAY_SERVICE_WORKER_URL, { type: "module" }); + // The path's *directory* is the scope: `/display/`, so the worker controls display pages and + // nothing else. + expect(DISPLAY_SERVICE_WORKER_URL).toBe("/display/sw.js"); + }); + + it("never lets a missing or refused registration break the display", async () => { + // Offline resilience is an enhancement. A browser without service workers, a page served over + // plain HTTP in development, and a policy that refuses all leave a display that works exactly as + // it did before — online only. + expect(await registerDisplayServiceWorker(undefined)).toBe(false); + expect( + await registerDisplayServiceWorker({ + register: vi.fn().mockRejectedValue(new Error("SecurityError")), + } as unknown as ServiceWorkerContainer), + ).toBe(false); + }); +});