feat: support split Dashboard and API origins (ADR 0045)
Merge after #52 (CI isolation) and #51 (ADR 0045). This branch contains #51's commits; once #51 merges, the diff is this one commit.
Expected rebase conflict with #52 in scripts/smoke-deploy.sh. #52 replaces the random octet= block with choose_edge_network, while this branch adds browser_address="10.249.${octet}.251" in that block. Resolve it by:
moving that line into choose_edge_network, next to session_client_address;
routing this branch's two up calls through compose_report_not_ready, as #52 does.
An installation can serve the Dashboard and the API on sibling hostnames (dash. / api.) beside the default same-origin topology, from one image, with configuration alone.
Changes
Runtime API origin.
surfaces/shell/index.html carries one empty <meta name="ekklesia-api-origin">.
At startup apps/api fills it with the identity base URL's origin, before computing the page's ETag (renderDashboardPage). Startup fails if the page lacks exactly one such element.
The shell parses the value strictly (resolveApiOrigin): a canonical http(s) origin, or empty meaning "same origin". Anything else renders a fixed failure and sends no request.
The build-time __EKKLESIA_API_BASE_URL__ is gone. The dev server injects the same element when EKKLESIA_API_BASE_URL is set.
The display host stays on the API's origin. It always uses its page origin; nothing else changes for screens.
The API trusts its own origin.apiTrustedOrigins() returns the trusted origins plus the base URL, matching Better Auth.
Doctor:
Now validates EKKLESIA_DASHBOARD_ORIGIN, which it previously skipped: the API refused to start where Doctor said pass.
Reports the topology, without printing any hostname.
Warns when split hostnames aren't siblings (IP addresses never count as siblings).
Module UI contract.ModuleUiApiClient documents the CORS limit: safelisted request headers plus Content-Type, and only safelisted response headers.
Deploy:
deploy/Caddyfile.split.example: the Dashboard hostname serves / and /assets/ekklesia-dashboard/*; the API hostname serves /api/*, /display/* and /assets/ekklesia-display/*; each refuses the other's paths.
EKKLESIA_DASHBOARD_HOSTNAME in compose.proxy.yaml and the env examples.
README section on the split topology.
Smoke test (EKKLESIA_SMOKE_TOPOLOGY=same-origin|split; CI runs both as a matrix). The split run:
builds an image with templates/module installed as the test-only module example (its lockfile importer is moved to match);
checks per-hostname routing and exact CORS;
drives headless Chromium (chromedp/headless-shell, pinned by digest) over CDP, with no new dependency;
signs in on dash. and checks the session cookie is host-only on api. with none on dash.;
mounts the module UI from api. and checks its credentialed request succeeds with no request refused;
mounts the display on api. and checks its service worker registers;
signs out.
Chromium trusts the test proxy via the SPKI hash of Caddy's internal intermediate CA; no CA is installed anywhere.
pnpm test:migrations:integration: 306/306. One test updated: the served page is now the built page with only its API origin written in. Run with S3_PORT=0 locally, because the suite pins storage to 8333 and a running dev stack holds that port; CI is unaffected.
scripts/smoke-deploy.sh: same-origin passes (30 checks); split passes end to end, including the 11 browser checks.
Not covered
Contribution artifacts in the browser run: the template's contribution targets a capability nothing provides. They use the same import() path as UI artifacts, whose CORS headers are tested.
Realtime CORS in the browser run: the template opens no stream. A new app.test.ts case covers credentialed SSE from the Dashboard origin and refusal for others.
## feat: support split Dashboard and API origins (ADR 0045)
**Merge after #52 (CI isolation) and #51 (ADR 0045).** This branch contains #51's commits; once #51 merges, the diff is this one commit.
**Expected rebase conflict with #52** in `scripts/smoke-deploy.sh`. #52 replaces the random `octet=` block with `choose_edge_network`, while this branch adds `browser_address="10.249.${octet}.251"` in that block. Resolve it by:
- moving that line into `choose_edge_network`, next to `session_client_address`;
- routing this branch's two `up` calls through `compose_report_not_ready`, as #52 does.
An installation can serve the Dashboard and the API on sibling hostnames (`dash.` / `api.`) beside the default same-origin topology, from one image, with configuration alone.
### Changes
- **Runtime API origin.**
- `surfaces/shell/index.html` carries one empty `<meta name="ekklesia-api-origin">`.
- At startup `apps/api` fills it with the identity base URL's origin, before computing the page's ETag (`renderDashboardPage`). Startup fails if the page lacks exactly one such element.
- The shell parses the value strictly (`resolveApiOrigin`): a canonical http(s) origin, or empty meaning "same origin". Anything else renders a fixed failure and sends no request.
- The build-time `__EKKLESIA_API_BASE_URL__` is gone. The dev server injects the same element when `EKKLESIA_API_BASE_URL` is set.
- **The display host stays on the API's origin.** It always uses its page origin; nothing else changes for screens.
- **The API trusts its own origin.** `apiTrustedOrigins()` returns the trusted origins plus the base URL, matching Better Auth.
- **Doctor:**
- Now validates `EKKLESIA_DASHBOARD_ORIGIN`, which it previously skipped: the API refused to start where Doctor said pass.
- Reports the topology, without printing any hostname.
- Warns when split hostnames aren't siblings (IP addresses never count as siblings).
- **Module UI contract.** `ModuleUiApiClient` documents the CORS limit: safelisted request headers plus `Content-Type`, and only safelisted response headers.
- **Deploy:**
- `deploy/Caddyfile.split.example`: the Dashboard hostname serves `/` and `/assets/ekklesia-dashboard/*`; the API hostname serves `/api/*`, `/display/*` and `/assets/ekklesia-display/*`; each refuses the other's paths.
- `EKKLESIA_DASHBOARD_HOSTNAME` in `compose.proxy.yaml` and the env examples.
- README section on the split topology.
- **Smoke test (`EKKLESIA_SMOKE_TOPOLOGY=same-origin|split`; CI runs both as a matrix).** The split run:
- builds an image with `templates/module` installed as the test-only module `example` (its lockfile importer is moved to match);
- checks per-hostname routing and exact CORS;
- drives headless Chromium (`chromedp/headless-shell`, pinned by digest) over CDP, with no new dependency;
- signs in on `dash.` and checks the session cookie is host-only on `api.` with none on `dash.`;
- mounts the module UI from `api.` and checks its credentialed request succeeds with no request refused;
- mounts the display on `api.` and checks its service worker registers;
- signs out.
Chromium trusts the test proxy via the SPKI hash of Caddy's internal intermediate CA; no CA is installed anywhere.
- **Docs:** `api.md`, `security.md` (launch checklist item 1; module import wording), ADR 0045 marked Accepted.
### Verification (local)
- `pnpm ci:verify`: passes.
- `pnpm test:migrations:integration`: 306/306. One test updated: the served page is now the built page with only its API origin written in. Run with `S3_PORT=0` locally, because the suite pins storage to 8333 and a running dev stack holds that port; CI is unaffected.
- `scripts/smoke-deploy.sh`: same-origin passes (30 checks); split passes end to end, including the 11 browser checks.
### Not covered
- Contribution artifacts in the browser run: the template's contribution targets a capability nothing provides. They use the same `import()` path as UI artifacts, whose CORS headers are tested.
- Realtime CORS in the browser run: the template opens no stream. A new `app.test.ts` case covers credentialed SSE from the Dashboard origin and refusal for others.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Rebased onto main after #52 (b642d8f), stacked on the rebased #51, and force-pushed. This PR's own commit is 65908ac.
The scripts/smoke-deploy.sh conflict with #52 is resolved as described. #52's choose_edge_network is kept, and browser_address moved into it next to session_client_address, so a re-chosen subnet moves the browser's address too. Both of this branch's up calls already go through #52's compose_report_not_ready.
CI is green on 65908ac: verify, infrastructure-smoke, migration-smoke, image-build, deploy-smoke (same-origin), and deploy-smoke (split), which includes the real-browser checks.
Rebased onto `main` after #52 (`b642d8f`), stacked on the rebased #51, and force-pushed. This PR's own commit is `65908ac`.
The `scripts/smoke-deploy.sh` conflict with #52 is resolved as described. #52's `choose_edge_network` is kept, and `browser_address` moved into it next to `session_client_address`, so a re-chosen subnet moves the browser's address too. Both of this branch's `up` calls already go through #52's `compose_report_not_ready`.
CI is green on `65908ac`: verify, infrastructure-smoke, migration-smoke, image-build, deploy-smoke (same-origin), and deploy-smoke (split), which includes the real-browser checks.
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]>
Rebased onto current main (after #51 and #53) and force-pushed. The branch is now one commit, f63f80f; the earlier "merge main into branch" commit (95dbd7d) is gone.
The ADR index conflict is resolved with both entries in number order: 0045 marked Accepted (this PR's change), then 0046 Proposed as on main. Apart from that line and ADR 0046's file from main, the tree is identical to the previously green 65908ac.
CI is green on f63f80f, all 6 jobs:
verify
infrastructure-smoke
migration-smoke
image-build
deploy-smoke (same-origin)
deploy-smoke (split)
Rebased onto current `main` (after #51 and #53) and force-pushed. The branch is now one commit, `f63f80f`; the earlier "merge main into branch" commit (`95dbd7d`) is gone.
The ADR index conflict is resolved with both entries in number order: 0045 marked Accepted (this PR's change), then 0046 Proposed as on `main`. Apart from that line and ADR 0046's file from `main`, the tree is identical to the previously green `65908ac`.
CI is green on `f63f80f`, all 6 jobs:
- verify
- infrastructure-smoke
- migration-smoke
- image-build
- deploy-smoke (same-origin)
- deploy-smoke (split)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
feat: support split Dashboard and API origins (ADR 0045)
Merge after #52 (CI isolation) and #51 (ADR 0045). This branch contains #51's commits; once #51 merges, the diff is this one commit.
Expected rebase conflict with #52 in
scripts/smoke-deploy.sh. #52 replaces the randomoctet=block withchoose_edge_network, while this branch addsbrowser_address="10.249.${octet}.251"in that block. Resolve it by:choose_edge_network, next tosession_client_address;upcalls throughcompose_report_not_ready, as #52 does.An installation can serve the Dashboard and the API on sibling hostnames (
dash./api.) beside the default same-origin topology, from one image, with configuration alone.Changes
Runtime API origin.
surfaces/shell/index.htmlcarries one empty<meta name="ekklesia-api-origin">.apps/apifills it with the identity base URL's origin, before computing the page's ETag (renderDashboardPage). Startup fails if the page lacks exactly one such element.resolveApiOrigin): a canonical http(s) origin, or empty meaning "same origin". Anything else renders a fixed failure and sends no request.__EKKLESIA_API_BASE_URL__is gone. The dev server injects the same element whenEKKLESIA_API_BASE_URLis set.The display host stays on the API's origin. It always uses its page origin; nothing else changes for screens.
The API trusts its own origin.
apiTrustedOrigins()returns the trusted origins plus the base URL, matching Better Auth.Doctor:
EKKLESIA_DASHBOARD_ORIGIN, which it previously skipped: the API refused to start where Doctor said pass.Module UI contract.
ModuleUiApiClientdocuments the CORS limit: safelisted request headers plusContent-Type, and only safelisted response headers.Deploy:
deploy/Caddyfile.split.example: the Dashboard hostname serves/and/assets/ekklesia-dashboard/*; the API hostname serves/api/*,/display/*and/assets/ekklesia-display/*; each refuses the other's paths.EKKLESIA_DASHBOARD_HOSTNAMEincompose.proxy.yamland the env examples.Smoke test (
EKKLESIA_SMOKE_TOPOLOGY=same-origin|split; CI runs both as a matrix). The split run:templates/moduleinstalled as the test-only moduleexample(its lockfile importer is moved to match);chromedp/headless-shell, pinned by digest) over CDP, with no new dependency;dash.and checks the session cookie is host-only onapi.with none ondash.;api.and checks its credentialed request succeeds with no request refused;api.and checks its service worker registers;Chromium trusts the test proxy via the SPKI hash of Caddy's internal intermediate CA; no CA is installed anywhere.
Docs:
api.md,security.md(launch checklist item 1; module import wording), ADR 0045 marked Accepted.Verification (local)
pnpm ci:verify: passes.pnpm test:migrations:integration: 306/306. One test updated: the served page is now the built page with only its API origin written in. Run withS3_PORT=0locally, because the suite pins storage to 8333 and a running dev stack holds that port; CI is unaffected.scripts/smoke-deploy.sh: same-origin passes (30 checks); split passes end to end, including the 11 browser checks.Not covered
import()path as UI artifacts, whose CORS headers are tested.app.test.tscase covers credentialed SSE from the Dashboard origin and refusal for others.🤖 Generated with Claude Code
a4966dd0ddto65908ac97cRebased onto
mainafter #52 (b642d8f), stacked on the rebased #51, and force-pushed. This PR's own commit is65908ac.The
scripts/smoke-deploy.shconflict with #52 is resolved as described. #52'schoose_edge_networkis kept, andbrowser_addressmoved into it next tosession_client_address, so a re-chosen subnet moves the browser's address too. Both of this branch'supcalls already go through #52'scompose_report_not_ready.CI is green on
65908ac: verify, infrastructure-smoke, migration-smoke, image-build, deploy-smoke (same-origin), and deploy-smoke (split), which includes the real-browser checks.95dbd7d4dctof63f80f2f0Rebased onto current
main(after #51 and #53) and force-pushed. The branch is now one commit,f63f80f; the earlier "merge main into branch" commit (95dbd7d) is gone.The ADR index conflict is resolved with both entries in number order: 0045 marked Accepted (this PR's change), then 0046 Proposed as on
main. Apart from that line and ADR 0046's file frommain, the tree is identical to the previously green65908ac.CI is green on
f63f80f, all 6 jobs: