feat: support split Dashboard and API origins (ADR 0045) #54

Merged
Zayin_Admin merged 1 commits from feat/split-origins into main 2026-10-03 19:53:52 +00:00
Member

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

## 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)
Zayin_Admin force-pushed feat/split-origins from a4966dd0dd to 65908ac97c 2026-10-03 18:29:20 +00:00 Compare
Author
Member

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.
Joey approved these changes 2026-10-03 18:47:37 +00:00
Joey closed this pull request 2026-10-03 18:55:14 +00:00
Joey reopened this pull request 2026-10-03 18:55:42 +00:00
Zayin_Admin added 1 commit 2026-10-03 19:47:54 +00:00
feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 49s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m4s
f63f80f2f0
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]>
Zayin_Admin force-pushed feat/split-origins from 95dbd7d4dc to f63f80f2f0 2026-10-03 19:47:54 +00:00 Compare
Author
Member

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)
Zayin_Admin merged commit b2efb4b029 into main 2026-10-03 19:53:52 +00:00
Zayin_Admin deleted branch feat/split-origins 2026-10-03 19:53:53 +00:00
Sign in to join this conversation.
No Reviewers
No labels
3 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Zayin/Ekklesia#54