CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance chooses System, Light, or Dark and reduced motion (System, Reduce, Allow). - Core: ekklesia_core_user_preference, one typed, CHECK-constrained, nullable column per preference (null = never chosen), migration 0008_user_preferences (additive), @ekklesia/core/account store, and the authenticated-baseline permission core.account.self.manage. - API: GET /api/v1/account (name, email, preferences) and partial PATCH /api/v1/account/preferences, self-only by construction: no user id is accepted anywhere; trusted Origin and body limit on the mutation. - Shell: the account control shows name, email, and initials (never the unvetted image URL) and offers Account, Appearance, Sign out; native radio groups apply a choice at once through the ADR 0060 controller and save it; the server's answer wins and a failed save reverts. - First paint: a classic, same-origin, render-blocking appearance.js in <head> applies a two-word localStorage cache before paint (no inline script, CSP unchanged). The account replaces the cache on every load, sign-out clears it, and other tabs follow via the storage event. - Deploy smoke: real-Chromium checks for light/dark, OS emulation, overrides, reload persistence, pre-paint, Shadow DOM modules, reduced motion, the display staying light, and phone width; the browser container gets a 512 MiB /dev/shm so a long session no longer fails requests with ERR_INSUFFICIENT_RESOURCES. Co-Authored-By: Claude Opus 5.5 <[email protected]>
192 lines
7.8 KiB
TypeScript
192 lines
7.8 KiB
TypeScript
import { moduleIdSchema } from "@ekklesia/contracts";
|
|
import { isReservedModuleId } from "@ekklesia/core/modules";
|
|
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
|
|
import { Hono } from "hono";
|
|
import { etagMatches } from "./conditional-request.js";
|
|
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
|
|
|
/**
|
|
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
|
|
* serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
|
|
* this server and the shell's router read one list), and at each installed module's `/<module-id>`.
|
|
* Every one of those first segments is either a reserved platform name or a module id Core accepted,
|
|
* and Core refuses a module id that is a reserved name, so no module page can shadow `/api`,
|
|
* `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display
|
|
* host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still
|
|
* lands on `/` with a query (ADR 0038).
|
|
*/
|
|
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
|
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
|
/** The pre-paint appearance script (ADR 0062), beside the application script. */
|
|
export const DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH = "/assets/ekklesia-dashboard/appearance.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, input.appearanceScript);
|
|
if (
|
|
snapshot.htmlEtag !== input.htmlEtag ||
|
|
snapshot.scriptEtag !== input.scriptEtag ||
|
|
snapshot.appearanceScriptEtag !== input.appearanceScriptEtag
|
|
) {
|
|
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,
|
|
});
|
|
}
|
|
|
|
export interface DashboardHostRouteOptions {
|
|
/**
|
|
* The id of every module this process discovered that declares a Dashboard UI — enabled or not,
|
|
* since a disabled module's bookmark should open the Dashboard and say the module is unavailable
|
|
* rather than fail as an unknown address. Fixed at startup, like the rest of the module set.
|
|
*/
|
|
readonly modulePages: readonly string[];
|
|
}
|
|
|
|
/** Each module page must be a real module id that names no platform namespace, exactly once.
|
|
* Discovery already guarantees this; the routes refuse to compose rather than trust it. */
|
|
function validatedModulePages(modulePages: readonly string[]): readonly string[] {
|
|
const seen = new Set<string>();
|
|
for (const moduleId of modulePages) {
|
|
if (
|
|
!moduleIdSchema.safeParse(moduleId).success ||
|
|
isReservedModuleId(moduleId) ||
|
|
DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) ||
|
|
seen.has(moduleId)
|
|
) {
|
|
throw new DashboardHostConfigurationError();
|
|
}
|
|
seen.add(moduleId);
|
|
}
|
|
return [...seen];
|
|
}
|
|
|
|
/**
|
|
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
|
* default same-origin shape.
|
|
*
|
|
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
|
|
* from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
|
|
* platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
|
|
* module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
|
|
* server change later) — always with the **same** bytes and the same validator, because the path
|
|
* selects a page inside the shell rather than a file here; that is what lets a refreshed or
|
|
* bookmarked deep link stay where it was.
|
|
*
|
|
* A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not
|
|
* installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does
|
|
* `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall 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,
|
|
options: DashboardHostRouteOptions,
|
|
): Hono {
|
|
const snapshot = snapshotAssets(assets);
|
|
const modulePages = validatedModulePages(options.modulePages);
|
|
const routes = new Hono();
|
|
|
|
const page = (ifNoneMatch: string | undefined): Response =>
|
|
fixedAsset(
|
|
ifNoneMatch,
|
|
snapshot.html,
|
|
snapshot.htmlEtag,
|
|
"text/html; charset=utf-8",
|
|
DASHBOARD_PAGE_SECURITY_HEADERS,
|
|
);
|
|
|
|
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
|
|
for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
|
|
routes.get(`/${name}`, (c) => page(c.req.header("if-none-match")));
|
|
routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match")));
|
|
}
|
|
|
|
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
|
fixedAsset(
|
|
c.req.header("if-none-match"),
|
|
snapshot.script,
|
|
snapshot.scriptEtag,
|
|
"text/javascript; charset=utf-8",
|
|
{},
|
|
),
|
|
);
|
|
|
|
// Same-origin, revalidated like the application script, and carrying nothing but code: the
|
|
// person's cached choice lives in their browser and is read by this script, never served by it.
|
|
routes.get(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, (c) =>
|
|
fixedAsset(
|
|
c.req.header("if-none-match"),
|
|
snapshot.appearanceScript,
|
|
snapshot.appearanceScriptEtag,
|
|
"text/javascript; charset=utf-8",
|
|
{},
|
|
),
|
|
);
|
|
|
|
return routes;
|
|
}
|