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]>
14 KiB
0045: The Dashboard and the API may live on separate origins
- Status: Accepted
- Date: 2026-10-03
- Amends: 0034
- Extends: 0044
- Related: ADR 0011, ADR 0012, ADR 0018, ADR 0025, ADR 0032, ADR 0033, ADR 0036, ADR 0038, ADR 0043
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:
<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, andSameSite=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.