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]>
74 lines
3.2 KiB
TypeScript
74 lines
3.2 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
|
|
|
/**
|
|
* 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
|
|
* a static directory. Keeping text rather than a mutable `Buffer` also makes the frozen snapshot a
|
|
* genuinely immutable value that every response can reuse safely.
|
|
*/
|
|
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
|
|
* location, and machine path are intentionally not retained or surfaced. */
|
|
export class DisplayHostAssetError extends Error {
|
|
readonly code = "DISPLAY_HOST_ASSET_UNAVAILABLE" as const;
|
|
|
|
constructor() {
|
|
super("The display host browser assets are unavailable.");
|
|
this.name = "DisplayHostAssetError";
|
|
this.stack = `${this.name}: ${this.message}`;
|
|
Object.freeze(this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Captures one immutable, internally consistent display-host asset snapshot.
|
|
*
|
|
* The strong validator is always derived from the exact script text in the same snapshot; callers
|
|
* 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,
|
|
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 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<DisplayHostAssets> {
|
|
try {
|
|
// Sequential deliberately: should both files be absent, diagnostics stay deterministic and no
|
|
// 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");
|
|
const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
|
|
return createDisplayHostAssets(html, script, serviceWorker);
|
|
} catch {
|
|
throw new DisplayHostAssetError();
|
|
}
|
|
}
|