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]>
124 lines
4.6 KiB
TypeScript
124 lines
4.6 KiB
TypeScript
import { Hono } from "hono";
|
|
import { etagMatches } from "./conditional-request.js";
|
|
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
|
|
|
/**
|
|
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
|
|
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
|
|
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
|
|
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
|
* `/api/v1/modules/<module-id>`.
|
|
*/
|
|
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
|
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
|
|
|
/**
|
|
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
|
|
* shell emits fixed, unhashed file names, so no response here may be cached as `immutable`: a
|
|
* rebuilt Dashboard must reach a browser on its next load, and an unchanged one costs a `304`.
|
|
*/
|
|
const DASHBOARD_HOST_CACHE_CONTROL = "no-cache";
|
|
|
|
/**
|
|
* Headers the Dashboard page carries on every answer, including a `304`, so a revalidated cache
|
|
* entry never keeps an older set.
|
|
*
|
|
* The page holds the platform sign-in form, so it refuses to be framed by anyone — `X-Frame-Options`
|
|
* for older browsers, and CSP `frame-ancestors`, the one directive a policy here carries until the
|
|
* full Content-Security-Policy is decided separately. `no-referrer` keeps the page's URL, which can
|
|
* briefly hold an emailed reset token before the shell strips it, out of every request it makes.
|
|
*/
|
|
const DASHBOARD_PAGE_SECURITY_HEADERS = Object.freeze({
|
|
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
"Referrer-Policy": "no-referrer",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"X-Frame-Options": "DENY",
|
|
});
|
|
|
|
/** A composition error, detected before any Dashboard route is registered. Carries no file path,
|
|
* package location, or asset content. */
|
|
export class DashboardHostConfigurationError extends Error {
|
|
constructor() {
|
|
super("The Dashboard host configuration is inconsistent.");
|
|
this.name = "DashboardHostConfigurationError";
|
|
this.stack = `${this.name}: ${this.message}`;
|
|
Object.freeze(this);
|
|
}
|
|
}
|
|
|
|
/** Copies and validates injected assets before registering any route, as the display host does, so
|
|
* a composition can never pair bytes with a validator computed from different bytes. */
|
|
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
|
|
try {
|
|
const snapshot = createDashboardHostAssets(input.html, input.script);
|
|
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
|
|
throw new DashboardHostConfigurationError();
|
|
}
|
|
return snapshot;
|
|
} catch {
|
|
throw new DashboardHostConfigurationError();
|
|
}
|
|
}
|
|
|
|
function fixedAsset(
|
|
ifNoneMatch: string | undefined,
|
|
body: string,
|
|
etag: string,
|
|
contentType: string,
|
|
extraHeaders: Readonly<Record<string, string>>,
|
|
): Response {
|
|
if (etagMatches(ifNoneMatch, etag)) {
|
|
return new Response(null, {
|
|
headers: { ...extraHeaders, "Cache-Control": DASHBOARD_HOST_CACHE_CONTROL, ETag: etag },
|
|
status: 304,
|
|
});
|
|
}
|
|
return new Response(body, {
|
|
headers: {
|
|
"X-Content-Type-Options": "nosniff",
|
|
...extraHeaders,
|
|
"Cache-Control": DASHBOARD_HOST_CACHE_CONTROL,
|
|
"Content-Type": contentType,
|
|
ETag: etag,
|
|
},
|
|
status: 200,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
|
* default same-origin shape.
|
|
*
|
|
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
|
|
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
|
|
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
|
|
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
|
|
* the sign-in form until `/api/v1/principal` says otherwise.
|
|
*/
|
|
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
|
|
const snapshot = snapshotAssets(assets);
|
|
const routes = new Hono();
|
|
|
|
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
|
|
fixedAsset(
|
|
c.req.header("if-none-match"),
|
|
snapshot.html,
|
|
snapshot.htmlEtag,
|
|
"text/html; charset=utf-8",
|
|
DASHBOARD_PAGE_SECURITY_HEADERS,
|
|
),
|
|
);
|
|
|
|
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
|
fixedAsset(
|
|
c.req.header("if-none-match"),
|
|
snapshot.script,
|
|
snapshot.scriptEtag,
|
|
"text/javascript; charset=utf-8",
|
|
{},
|
|
),
|
|
);
|
|
|
|
return routes;
|
|
}
|