feat: let a display survive its network — as code, not as authorization

A display is the one surface expected to outlive its network: a television on
a wall in a building whose uplink is a consumer router, with nobody standing
in front of it. When that uplink drops and the screen reboots, the browser
shows its own error page — not the display shell, not an Ekklesia message,
nothing that recovers on its own, because there is no Ekklesia code left
running to recover.

Cache the shell and the declared code, in a service worker scoped to
`/display/`: the page, the host script, the module's display artifact, and
every contributed renderer that display actually mounted. All of them are code
whose bytes already confer no authority, and all of them are addressed by a
URL carrying an exact version.

Cache nothing else, ever. Not the descriptor, not `/api/v1/principal`, not a
module or platform API response, not the realtime stream, not a pairing
exchange, and not any cross-origin request — which is what a signed
object-delivery URL is. It is an allowlist rather than a deny list, so the
cost of forgetting a new endpoint is that code goes uncached rather than that
a permission decision gets stored.

The consequence is stated rather than papered over: a display cannot render
module content while offline. Mounting needs a descriptor, a descriptor is
authorization-bearing, and it is never cached — so a revoked device stays
revoked and cached code can never manufacture current authorization. What the
cache buys is that a kiosk rebooting mid-outage boots into Ekklesia's own
offline state and keeps retrying, that reconnecting mounts with nothing left
to download, and that a hiccup fetching 600 KB of artifact after the tiny
descriptor already resolved falls back to identical bytes instead of failing
the display.

Network-first everywhere, so a screen picks up new code the moment the server
returns and can never be trapped on an obsolete build — the failure mode a
cache-first worker produces on a screen nobody visits. Only a complete
same-origin 200 is stored. Cache names carry a policy version and superseded
generations are deleted; artifacts are pruned to the current version per
module, keeping every artifact belonging to that version because a display
artifact and its contributed renderers share one. The worker never caches
itself.

The page gets a small signal built from two sources, because either alone
lies: `navigator.onLine` is optimistic behind a captive portal, and only the
worker knows it actually served from cache. A display shows a quiet corner
indicator rather than blanking a lobby wall, and resolves its descriptor again
by itself when the network returns — nobody presses Retry on a lobby TV.

Every caching decision lives in a module that touches no global, so the
behaviour is exercised directly rather than through a worker a test runner
cannot host. Registration is an enhancement: no service workers, plain HTTP,
or a refusing policy all leave a display that works exactly as before.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-27 16:08:34 -05:00
co-authored by Claude Opus 5
parent e36485bf99
commit ca3f37d5f9
22 changed files with 1726 additions and 29 deletions
@@ -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<string, Response> } {
const entries = new Map<string, Response>();
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<string, ReturnType<typeof fakeCache>>;
readonly statuses: DisplayOfflineStatus[];
online: boolean;
/** Responses the "server" will give, by URL. Absent means a 404. */
readonly served: Map<string, () => Response>;
readonly requested: string[];
}
function harness(): Harness {
const caches = new Map<string, ReturnType<typeof fakeCache>>();
const statuses: DisplayOfflineStatus[] = [];
const served = new Map<string, () => 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("<html>display</html>", { 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<Response | undefined> {
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("<html>display</html>");
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();
});
});