feat(api): serve the built Dashboard from the API origin
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
220b7b152a
commit
a12cbe9600
@@ -0,0 +1,69 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user