feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Failing after 25s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m17s
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Failing after 25s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m17s
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:
co-authored by
Claude Opus 5.5
parent
2323467aef
commit
a4966dd0dd
+40
-17
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user