Serve surfaces/shell's built Dashboard from apps/api on the same origin as /api (ADR 0044), exactly as the display host is served: a closed set of files read once at startup, never a static directory, never a path built from request input. - The shell exports @ekklesia/shell/dashboard-assets (the exact built index.html and script URLs), emits the script at assets/ekklesia-dashboard/app.js, and index.html names that absolute path so dev and production serve the same URLs. - GET / and GET /assets/ekklesia-dashboard/app.js answer from a frozen snapshot: no-cache with a strong ETag and 304 on match. Source maps are never served; every other path is the ordinary sanitized 404. - The page sets nosniff, X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none' (that directive only), and Referrer-Policy: no-referrer, on 200 and 304 alike. - A missing or empty shell build fails startup with a fixed DashboardHostAssetError before any module code is imported. - The If-None-Match comparison moves to conditional-request.ts, shared with the display host routes. - The emailed-link token stripping moves from browser/main.tsx into consumeAuthLanding (location and history injected) so it is tested; behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
70 lines
2.8 KiB
TypeScript
70 lines
2.8 KiB
TypeScript
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),
|
|
});
|
|
}
|
|
|
|
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
|
|
* than leaving an API whose own origin answers `/` with a 404. */
|
|
export async function loadDashboardHostAssets(): Promise<DashboardHostAssets> {
|
|
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(html, script);
|
|
} catch {
|
|
throw new DashboardHostAssetError();
|
|
}
|
|
}
|