Compare commits
3
Commits
v0.1.2
...
dc0a3c38ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc0a3c38ac | ||
|
|
2323467aef | ||
|
|
fe390bb09d |
@@ -0,0 +1,233 @@
|
|||||||
|
# 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 session cookie is present only for the API hostname, the Dashboard imports a
|
||||||
|
module's UI from the API hostname and the module's own credentialed requests succeed under CORS, no
|
||||||
|
request is refused, the module's display page mounts on the API hostname with its service worker, 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. Contribution
|
||||||
|
artifacts are imported exactly as UI artifacts are, and the API's tests cover their CORS headers and
|
||||||
|
those of realtime streams.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -53,3 +53,4 @@ 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
|
- [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
|
- [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
|
- [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
|
||||||
|
|||||||
Reference in New Issue
Block a user