Files
Ekklesia/docs/architecture/decisions/0045-split-dashboard-and-api-origins.md
T
ekklesiaandClaude Opus 5.5 a56a853319 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 13:29:11 -05:00

14 KiB

0045: The Dashboard and the API may live on separate origins

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, 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.