import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets"; /** * The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044). * * Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound, * from the exact URLs the surface package exports. Request input is never involved in this read, the * package is never exposed as a static directory, and the source map beside the script is never * read. Text rather than a mutable `Buffer` keeps the frozen snapshot a genuinely immutable value * every response can reuse. */ export interface DashboardHostAssets { readonly html: string; readonly htmlEtag: string; readonly script: string; readonly scriptEtag: 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 DashboardHostAssetError extends Error { readonly code = "DASHBOARD_HOST_ASSET_UNAVAILABLE" as const; constructor() { super("The Dashboard host browser assets are unavailable."); this.name = "DashboardHostAssetError"; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } function strongEtag(text: string): string { return `"${createHash("sha256").update(text).digest("base64url")}"`; } /** * Captures one immutable, internally consistent Dashboard asset snapshot. * * Each strong validator is derived from the exact text in the same snapshot, so bytes from one build * can never be paired with an ETag from another. Empty output is a failed shell build rather than a * valid page that can never render anything. */ export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets { if (html.trim().length === 0 || script.trim().length === 0) { throw new DashboardHostAssetError(); } return Object.freeze({ html, htmlEtag: strongEtag(html), script, scriptEtag: strongEtag(script), }); } /** * The exact element the built Dashboard page carries for its API origin (ADR 0045), empty. The shell * reads the filled-in value at run time; empty means the page's own origin. */ export const DASHBOARD_API_ORIGIN_PLACEHOLDER = ''; function isCanonicalHttpOrigin(value: string): boolean { try { const url = new URL(value); return (url.protocol === "https:" || url.protocol === "http:") && url.origin === value; } catch { return false; } } /** * Writes the API's own origin into the Dashboard page, once, before the page's `ETag` is computed. * * The origin is the identity base URL's, which is where `/api` lives in either topology: the page's * own origin when the Dashboard and the API share one, the API hostname when they are split. The * built page must carry exactly one empty placeholder; anything else is a shell build this API does * not understand, and startup fails rather than serving a page that might send credentials somewhere * unintended. The value is escaped for an attribute even though a canonical origin cannot contain * any character that needs it. */ export function renderDashboardPage(html: string, apiOrigin: string): string { const parts = html.split(DASHBOARD_API_ORIGIN_PLACEHOLDER); if (parts.length !== 2 || !isCanonicalHttpOrigin(apiOrigin)) { throw new DashboardHostAssetError(); } const escaped = apiOrigin .replaceAll("&", "&") .replaceAll('"', """) .replaceAll("<", "<") .replaceAll(">", ">"); return parts.join(``); } /** Loads only the two surface-declared files, and renders the API origin into the page. A missing, * unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers * `/` with a 404 or a page that cannot find its API. */ export async function loadDashboardHostAssets(apiOrigin: string): Promise { try { // Sequential deliberately, as for the display host: diagnostics stay deterministic and no // rejected sibling read is left running after this function has already failed. const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8"); const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8"); return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script); } catch { throw new DashboardHostAssetError(); } }