feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 49s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m4s

An installation can now serve the Dashboard and the API on sibling
hostnames (dash./api.) beside the default same-origin topology, from one
image and with configuration alone.

- The Dashboard learns its API origin at run time: the built page carries
  one empty <meta name="ekklesia-api-origin">, which the API fills with its
  identity base URL's origin at startup, before computing the page's ETag.
  The shell accepts only a canonical http(s) origin and otherwise fails
  closed without making a request. The build-time EKKLESIA_API_BASE_URL is
  gone from the bundle; the development server injects the same element.
- The display host stays on the API's origin, so its device cookies,
  requests, and offline cache are unchanged.
- The API trusts its own origin (trusted origins plus the identity base
  URL), as Better Auth already does, so the display page's mutations pass
  the origin check without operators listing it.
- Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup
  does (it previously skipped it), reports the topology without printing
  hostnames, and warns when split hostnames are not siblings.
- The module UI contract states the CORS header limit (safelisted headers
  plus Content-Type, safelisted response headers only).
- Reference deployment: Caddyfile.split.example (Dashboard hostname serves
  / and its script; API hostname serves /api/*, /display/*, and the
  display script; each refuses the other's paths) and
  EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples.
- The deploy smoke test runs either topology (CI runs both). The split run
  installs the module template as a test-only module, checks per-hostname
  routing and exact CORS, then drives a digest-pinned headless Chromium
  over the DevTools protocol, with no added dependency: sign-in on the
  Dashboard hostname, the session cookie host-only on the API hostname,
  the module UI imported cross-origin and its credentialed request
  succeeding, the display and its service worker on the API hostname, and
  sign-out.

ADR 0045 is marked Accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-03 14:47:36 -05:00
co-authored by Claude Opus 5.5
parent cbf5962d04
commit f63f80f2f0
32 changed files with 1275 additions and 116 deletions
@@ -1,6 +1,6 @@
# 0045: The Dashboard and the API may live on separate origins
- Status: Proposed
- Status: Accepted
- Date: 2026-10-03
- Amends: [0034](0034-host-owned-browser-authentication.md)
- Extends: [0044](0044-self-hosted-reference-deployment.md)
+1 -1
View File
@@ -53,5 +53,5 @@ ADRs capture durable choices that affect more than one package or phase.
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Accepted
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Proposed
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Accepted
- [0046: Ekklesia's branding is never forced on an installation](0046-installation-owned-branding.md) — Proposed
+40 -17
View File
@@ -62,7 +62,7 @@ No real values are shown here — configure per environment and never commit the
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
| `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. |
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. |
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. The base URL's own origin is always trusted as well and need not be listed ([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). |
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
@@ -77,7 +77,7 @@ 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 /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), with the API's origin written into it. Served from the API's origin, or from the Dashboard's own hostname through the proxy in the split topology. `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). |
@@ -140,19 +140,29 @@ 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:
`apps/api` serves the built Dashboard itself
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). In the default
same-origin topology it does so from the same origin as `/api`: `EKKLESIA_DASHBOARD_ORIGIN` stays
unset, the session cookie stays host-only, and no second static server or proxy file root is
needed. In the split topology
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the proxy forwards
the Dashboard's own hostname to the same two routes; the API does not inspect `Host`. 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
- **The API's origin is written in at startup.** The built page carries one empty
`<meta content="" name="ekklesia-api-origin" />`. Before computing the page's `ETag`, `startApi()`
fills it with the identity base URL's origin, which is where `/api` lives in either topology. The
shell reads it before rendering, uses the page's own origin when it is empty, and fails closed,
making no request, when it is anything but a canonical `http(s)` origin. Nothing about the topology
is built into the bundle.
- **A missing or incompatible build fails startup.** An absent, unreadable, or empty file, or a page
without exactly one empty API-origin element, 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
@@ -195,7 +205,9 @@ 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.
that path absolutely, so the page never depends on the URL that served it. For a split-origin
development API, set `EKKLESIA_API_BASE_URL` for the development server: it writes that origin into
its copy of the page exactly as the API does in production. A production build ignores it.
## Dashboard browser authentication
@@ -340,7 +352,10 @@ real server: 7 characters rejected, 8 accepted, 128 accepted, 129 rejected.
## Origin, CORS, and cookies
The API reuses the same `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` list for both CORS and Better Auth's
own origin/CSRF checks — there is no second, competing configuration. CORS reflects a request's
own origin/CSRF checks — there is no second, competing configuration. Like Better Auth, the API also
trusts the identity base URL's own origin without it being listed, so a page the API itself serves
(the display host, or the Dashboard in the same-origin topology) passes the API's origin checks
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). CORS reflects a request's
`Origin` header back only when it exactly matches a configured trusted origin (never a wildcard,
since `Access-Control-Allow-Origin: *` cannot be combined with `Access-Control-Allow-Credentials:
true`); a disallowed origin never receives the header at all, though the request itself may still
@@ -374,10 +389,18 @@ Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
CORS/CSRF above. The Dashboard does not broaden this policy: same-origin `/api` proxying is the
simplest production topology, while a deliberately split browser/API origin must use the exact
trusted-origin allowlist and credentialed CORS. Sibling subdomains do not implicitly share the
cookie. No production Dashboard hostname or proxy deployment exists in this repository.
CORS/CSRF above. The Dashboard does not broaden this policy. In the split topology
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the cookie stays
host-only on the API's hostname, and the Dashboard on a sibling hostname reaches the API with
credentialed CORS; browsers send the cookie because the two hostnames are origins of one site, which
is therefore a requirement of that topology. The reference deployment provides both topologies
([`deploy/README.md`](../../deploy/README.md#split-dashboard-and-api-origins)).
**CORS request and response headers are deliberately narrow, and part of the module UI contract.**
Beyond the CORS-safelisted request headers, only `Content-Type` is allowed, and no response header is
exposed beyond the safelisted ones. A module UI's `api.request` inherits this policy, so portable
module UI code sends only safelisted headers plus `Content-Type` and reads only safelisted response
headers. That keeps one module artifact working in either topology.
## Logging
@@ -1613,7 +1636,7 @@ 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; 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
serves the built Dashboard ([Dashboard host](#dashboard-host)) in either topology, while the 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.
+5 -2
View File
@@ -153,7 +153,8 @@ enforced by `requirePermission` regardless; the test suite proves it by having a
"denied" call its API anyway and still receive `403`.
**The host executes trusted module code in its own page and does not pretend otherwise.** The loader
performs a same-origin dynamic `import()` — no `eval`, no `new Function`, no `blob:` URL — and the
performs a dynamic `import()` from the API's origin (the page's own in the same-origin topology, the
API hostname in the split one, ADR 0045) — no `eval`, no `new Function`, no `blob:` URL — and the
shell owns the container, the loading state, and every error, so a failing module costs its own panel
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
JavaScript, exactly as described above.
@@ -493,7 +494,9 @@ outstanding.
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)), and the
reference deployment's proxy routes it ([deploy/README.md](../../deploy/README.md#launch-checklist)
maps every item here to its configuration).
maps every item here to its configuration). A split installation (ADR 0045) puts its Dashboard
and API hostnames on one registrable domain and uses `Caddyfile.split.example`, which refuses
`/api/*` on the Dashboard's hostname so the session cookie has exactly one home.
2. The origin port is unreachable from the public Internet.
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.