Files
Ekklesia/docs/architecture/decisions/0045-split-dashboard-and-api-origins.md
T
ekklesiaandClaude Opus 5.5 fe390bb09d docs: propose ADR 0045, split Dashboard and API origins
Records how an installation runs the Dashboard and the API on sibling
hostnames (dash./api.) beside the default same-origin topology, from one
image: the API origin reaches the Dashboard at run time in a meta element,
the display host stays on the API's origin (device cookies and the offline
code cache depend on it), the API trusts its own origin as Better Auth
already does, cookies stay host-only, CORS stays narrow and becomes part of
the module UI contract, the reference proxy gains per-hostname routing, and
the deploy smoke test proves both topologies, the split one in a real
browser.

Amends ADR 0034 and extends ADR 0044.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 11:03:28 -05:00

231 lines
14 KiB
Markdown

# 0045: The Dashboard and the API may live on separate origins
- Status: Proposed
- Date: 2026-10-03
- Amends: [0034](0034-host-owned-browser-authentication.md)
- Extends: [0044](0044-self-hosted-reference-deployment.md)
- Related: [ADR 0011](0011-identity-http-transport-and-public-enrollment.md),
[ADR 0012](0012-identity-origin-csrf-protection-under-test.md),
[ADR 0018](0018-identity-session-resolution-and-database-transport.md),
[ADR 0025](0025-trusted-module-ui-asset-publication.md),
[ADR 0032](0032-native-module-realtime-transport.md),
[ADR 0033](0033-display-kiosk-host-contract.md),
[ADR 0036](0036-managed-device-identity-and-pairing.md),
[ADR 0038](0038-email-verification-and-password-recovery.md),
[ADR 0043](0043-display-offline-code-fallback.md)
## Context
ADR 0044's reference deployment serves everything a browser needs from one origin: the Dashboard at
`/`, the API at `/api/*`, and the display host at `/display/*`. ADR 0034 kept a deliberately split
browser/API origin possible through the exact trusted-origin allowlist and credentialed CORS of
ADR 0011, ADR 0012, and ADR 0018, but left that topology undesigned: "any eventual choice to place
the API and Dashboard on different sibling subdomains must be designed and tested rather than
assumed to share a session."
The first production installation needs exactly that: the Dashboard and the API on sibling
hostnames (for example `dash.example.org` and `api.example.org`), plus the existing storage
hostname. The choice has to be made before the first deployment, because a display's bookmark and a
paired device's cookies are bound to a hostname: moving them later re-pairs every screen.
Most of the platform was already written for this. The shell sends every platform and module request
with `credentials: "include"` and opens realtime with `withCredentials: true`; CORS on `/api/auth/*`
and `/api/v1/*` reflects only exact trusted origins with credentials; module artifacts are anonymous
by design so that cross-origin `import()` works (ADR 0025); emailed links already pass through the
API and land on `EKKLESIA_DASHBOARD_ORIGIN` (ADR 0038); and display page URLs are resolved against the
API's origin. Four things were not:
- the Dashboard learns its API origin at **build** time (`EKKLESIA_API_BASE_URL`), so one image
cannot serve both topologies;
- the API's own set of trusted browser origins is exactly `EKKLESIA_IDENTITY_TRUSTED_ORIGINS`, while
Better Auth implicitly adds the identity base URL to its own: a page served by the API itself would
fail the API's origin check unless an operator listed the API's own origin too;
- the reference proxy has no routing for two browser-facing hostnames;
- nothing proves a split deployment end to end, and a Node.js client cannot prove what matters most
here: what a real browser sends across the two origins.
## Decision
### Two topologies, one image
An installation runs in exactly one of two shapes, chosen by configuration alone:
| Topology | Dashboard | API and display host | Storage |
| --- | --- | --- | --- |
| **Same-origin** (default) | `https://ekklesia.example.org/` | `https://ekklesia.example.org/api/*`, `/display/*` | its own hostname |
| **Split** | `https://dash.example.org/` | `https://api.example.org/api/*`, `/display/*` | its own hostname |
Same-origin remains the default and keeps working unchanged for every installation that does not
opt in. The same image and the same built Dashboard serve both.
In both, the API process serves the Dashboard's bytes (ADR 0044 is unchanged in that). In the split
topology the proxy forwards the Dashboard hostname's two Dashboard paths to the same `api` service;
the API does not inspect `Host`, and which hostname serves which path is the proxy's job, as every
other perimeter rule already is.
### The Dashboard learns its API origin at run time
The API writes its own origin into the Dashboard page it serves, as one meta element:
```html
<meta name="ekklesia-api-origin" content="https://api.example.org" />
```
The value is the identity base URL's origin (`EKKLESIA_IDENTITY_BASE_URL`), which is by definition
where `/api` lives; no new setting is introduced. The built page carries the element with an empty
value; the API fills it once at startup, before computing the page's strong `ETag`, and refuses to
start if the built page does not contain exactly one such element. The shell reads it before
anything renders, accepts only an absolute `http(s)` origin with no path, and otherwise fails closed
with its existing sanitized bootstrap failure. An empty value (a static development server with no
API) means the page's own origin.
A meta element rather than an inline script keeps the page free of inline script, so a future strict
`script-src` needs no exception. The build-time `EKKLESIA_API_BASE_URL` is removed; the development
server injects the same element instead. The display host needs neither: it is always served from
the API's origin (below) and uses its own.
### The display host lives on the API's origin
In the split topology the display host stays where it is: `https://api.example.org/display/<module>`,
with its script under `/assets/ekklesia-display/` and its service worker at `/display/sw.js`. A
screen's bookmark names the API hostname.
- **Device cookies are already bound to the API's hostname.** They are host-only, `HttpOnly`, and
`SameSite=Strict` (ADR 0036), and they must accompany device requests to the API, so they live on
whichever hostname answers those requests. Serving the display page from that same origin makes it
the only hostname a screen depends on; serving it anywhere else adds a second one.
- **Offline code fallback is unchanged.** The display's service worker never caches a cross-origin
response (ADR 0043), which is what keeps signed storage URLs, bearer capabilities, out of the
cache. On the API's origin, every artifact it must cache is same-origin. On any other origin, every
artifact would be cross-origin, and the offline guarantee would need a weaker rule.
- **No device request crosses an origin.** Pairing, heartbeats, descriptors, module routes, and
realtime from an unattended screen stay same-origin, with no CORS dependence.
The Dashboard keeps linking to display pages exactly as now: their URLs are resolved against the API
origin.
### The API trusts its own origin
The API's set of trusted browser origins, used for CORS on `/api/auth/*` and `/api/v1/*` and by every
mutation route's origin check, becomes `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` **plus the identity base
URL's origin**, matching what Better Auth already does for its own checks. In the split topology the
display page on the API's origin therefore passes the API's origin check without an operator having
to list it. Trusted origins remain exact: no wildcard, no pattern, and no parent domain.
### Cookies and identity: unchanged, with one stated requirement
The session cookie keeps every property ADR 0034 gave it: `HttpOnly`, `SameSite=Lax`, `Secure` under
HTTPS, `Path=/`, and **host-only on the API's hostname**. No `Domain` attribute is set, and the
Dashboard's hostname never holds a session credential. The Dashboard reaches the API with credentialed
CORS, and browsers send the cookie because the two hostnames are different origins of the **same
site**.
That is a requirement of the split topology: the Dashboard and API hostnames must share a registrable
domain (`dash.example.org` and `api.example.org` do; `dash.example.org` and `api.example.net` do not).
Across sites, browsers withhold `SameSite=Lax` cookies from cross-origin requests and may block or
partition third-party cookies altogether. The documentation states it, and Doctor reports the
topology and warns when the two hostnames are not siblings under one parent domain, because Doctor
cannot see the Public Suffix List and says so.
In the split topology `EKKLESIA_IDENTITY_BASE_URL` is the API's origin, `EKKLESIA_DASHBOARD_ORIGIN` is
the Dashboard's, and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` lists the Dashboard's. Email verification and
password reset links already point at the API and land on `EKKLESIA_DASHBOARD_ORIGIN` (ADR 0038);
nothing about them changes.
### CORS stays narrow, and that limit is part of the module contract
Credentialed CORS keeps its current shape: exact reflected origin, `Access-Control-Allow-Credentials:
true`, the existing method list, `Content-Type` as the only allowed request header beyond the
CORS-safelisted ones, and no exposed response headers beyond the safelisted ones. The API does not
reflect arbitrary requested headers.
A module UI's requests go through the host's `api.request` and inherit this policy. The module UI
contract therefore states it: portable module UI code uses only CORS-safelisted request headers plus
`Content-Type`, and reads only CORS-safelisted response headers. That is already true of every
existing module, and it keeps one module artifact working under either topology. Widening it for a
real module need is a later, explicit change.
### Routing per hostname
The reference proxy gains a second configuration for the split topology, beside the existing one:
| Hostname | Forwarded to `api` | Refused with `404` |
| --- | --- | --- |
| Dashboard | `/` (emailed links add only a query) and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
| API | `/api/*` (realtime flushed per event, as now), `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` and `/assets/ekklesia-dashboard/*` |
| Storage | unchanged (ADR 0031) | unchanged |
The Dashboard hostname refuses `/api/*` so that the session cookie has exactly one home: a Dashboard
cannot fall back to a same-origin API on its own hostname. The API hostname answers nothing at `/`.
Both hostnames keep every forwarding-header rule ADR 0044 established (one overwritten
`X-Forwarded-For`, the stripped client-chosen headers, and tunnel trust only from the connector's
address). The proxy overlay takes the Dashboard hostname from one more setting, which the split
configuration requires and the same-origin configuration ignores.
### What a future Content-Security-Policy must allow
This decision does not add a Content-Security-Policy; the Dashboard page keeps `frame-ancestors
'none'` and its other headers. When a full policy is designed, the split topology requires the
Dashboard's to allow the API origin in `script-src` (module artifacts and contributions are imported
from it) and `connect-src` (platform and module requests, realtime), and the storage origin in
`img-src`. The display page, being same-origin with the API, needs only its own origin and the
storage origin.
### Both topologies are proven end to end
The deployment smoke test runs in either topology, and CI runs both. The split run proves, through
the proxy, the routing table above on every hostname, the API origin in the Dashboard page, exact
CORS reflection with credentials for the Dashboard origin and none for an untrusted origin, and the
host-only session cookie.
Because those are browser behaviors, the split run also drives a **real browser**: a headless
Chromium from an image pinned by digest, controlled over the Chrome DevTools Protocol by the
smoke-test client with no added dependency. In it, a person signs in through the real form on the
Dashboard hostname, the Dashboard loads a module's UI and contribution from the API hostname, the
session cookie is present only for the API hostname, the module's display page mounts on the API
hostname, and sign-out ends the session. The split run installs the repository's module template as
a test-only module so these paths exist; installations' own smoke tests cover their real modules.
## Consequences
- One image serves both topologies; an installation chooses with configuration and the matching proxy
configuration.
- Same-origin installations change nothing.
- A split installation depends on exactly two browser-facing hostnames plus storage. Screens and
paired devices depend only on the API hostname, which therefore must not change once screens are
paired.
- No credential is broadened: the session cookie stays host-only on the API hostname, device cookies
stay host-only and strict, and trusted origins stay exact.
- Module UI code is held to CORS-safe headers in both topologies, so a module that works in one
works in the other.
- CI gains a second deployment smoke run and a browser in it.
## Alternatives
- **A parent-domain session cookie (`Domain=example.org`).** Rejected, as ADR 0034 already rejected
it: it sends the credential to every sibling host and is unnecessary between same-site origins.
- **The display host on the Dashboard hostname, or on its own hostname.** Rejected: device cookies
are bound to the API's hostname regardless, so either adds a second hostname every screen depends
on, puts every device request behind CORS, and breaks ADR 0043's same-origin-only offline cache.
- **The API serving the display host and the device API on a second hostname.** Rejected: two API
origins for one identity base URL, with the same cookies split between them.
- **Inject the API origin with an inline script, or a configuration request.** Rejected: an inline
script needs a CSP exception, and a request is a round trip and a failure mode before the first
render; a meta element is static and inert.
- **Keep the API origin a build-time setting.** Rejected: the image would have to be built per
installation and per topology.
- **Require operators to list the API's own origin in the trusted origins.** Rejected: an omission
silently breaks every display mutation; Better Auth already trusts its own base URL.
- **Reflect any requested CORS header.** Rejected for now: no module needs it, and a module that
relied on it would be tied to its host's CORS policy.
- **Prove split mode with HTTP checks only.** Rejected: whether a browser sends the cookie, imports
a cross-origin module, and opens a credentialed stream is exactly what is at stake.
## Deferred
- A full Content-Security-Policy, with the requirements above.
- A broader CORS header policy, when a module needs one.
- More than one Dashboard origin per installation.
- Moving an installation between topologies, and the re-pairing that moving the API hostname
implies.