Files
Ekklesia/apps/api/src/dashboard-host-routes.ts
T
ekklesiaandClaude Opus 5.5 cd0287537e
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
Add the account area and account-synced Appearance preferences
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]>
2026-10-14 23:56:50 -05:00

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;
}