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 { 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(); } }