feat(api): serve the built Dashboard from the API origin
CI / verify (pull_request) Successful in 46s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m44s

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:
ekklesia
2026-10-01 17:54:55 -05:00
co-authored by Claude Opus 5.5
parent 220b7b152a
commit a12cbe9600
32 changed files with 1041 additions and 56 deletions
+65 -3
View File
@@ -77,6 +77,8 @@ No real values are shown here — configure per environment and never commit the
| `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. |
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), served from the same origin as `/api`. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
| `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). |
| `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
@@ -136,6 +138,65 @@ a new raw token when asked to revoke other sessions. The principal response neve
contains email, name, image, session id or expiry, session token, account/provider details, or any
role, permission, membership, or volunteer data.
## Dashboard host
`apps/api` serves the built Dashboard itself, from the same origin as `/api`
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). That keeps the
identity system's default same-origin shape: `EKKLESIA_DASHBOARD_ORIGIN` stays unset, the session
cookie stays host-only, and no second static server or proxy file root is needed. It works exactly
like the display host:
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the
exact URLs of two built files, `dist/browser/index.html` and
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256
strong `ETag` for each.
- **A missing build fails startup.** An absent, unreadable, or empty file stops `startApi()` with a
fixed `DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
already opened. It never carries a path or the filesystem error. Build the shell first;
`pnpm api:start` already runs the full build.
- **Two exact routes, never a directory.** `GET /` answers the page and
`GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and
nothing touches the filesystem while answering. Every other path, including `/index.html`,
`/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary
`404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client
router, and emailed links land on `/` with a query (see
[The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's
implicit `HEAD`) are also the ordinary `404`.
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two
files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
a `404` for it.
Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's
CORS policy does not apply. The bytes grant no authority: the page renders sign-in until
`/api/v1/principal` says otherwise.
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` |
| --- | --- | --- |
| `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` |
| `Cache-Control` | `no-cache` | `no-cache` |
| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script |
| `X-Content-Type-Options` | `nosniff` | `nosniff` |
| `X-Frame-Options` | `DENY` | — |
| `Content-Security-Policy` | `frame-ancestors 'none'` | — |
| `Referrer-Policy` | `no-referrer` | — |
**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every
load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match`
(strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and
`Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry
keeps them. Neither file is ever `immutable`: a rebuilt Dashboard reaches a browser on its next load.
**Page security headers.** The page holds the platform sign-in form, so it refuses to be framed,
through `X-Frame-Options` and CSP `frame-ancestors`. That is the only directive the policy carries:
the full Content-Security-Policy is a separate decision. `no-referrer` keeps the page URL out of
every request the page makes, which matters while an emailed reset token is briefly in it. The shell
strips that token from the address bar before it renders anything.
**Development.** `pnpm --filter @ekklesia/shell dev` serves `dist/browser` at the same absolute
paths, because the build emits the script at `assets/ekklesia-dashboard/app.js`. `index.html` names
that path absolutely, so the page never depends on the URL that served it.
## Dashboard browser authentication
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
@@ -1551,7 +1612,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
See the repository root [README](../../README.md#current-status) and
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
for what has landed across every phase and what remains. Surface work is underway; production
Dashboard asset hosting, enrollment/recovery, display device identity, and public Website routing
remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
for what has landed across every phase and what remains. Surface work is underway; the API now
serves the built Dashboard ([Dashboard host](#dashboard-host)), while its production proxy, TLS,
full Content-Security-Policy, enrollment/recovery, display device identity, and public Website
routing remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
database-composition contracts as they evolve.