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
+170
View File
@@ -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<unknown>,
moduleId = "announcements",
connectivity?: DisplayConnectivitySource,
): Promise<void> {
await act(async () => {
root.render(
<DisplayApp
config={config}
{...(connectivity ? { connectivity } : {})}
{...(importModule ? { importModule } : {})}
moduleId={moduleId}
/>,
@@ -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<string, Set<() => 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();
});
});