43 Commits
Author SHA1 Message Date
The_River fc504eee0a Merge pull request 'chore(release): prepare 0.1.2' (#62) from chore/release-0.1.2 into main
CI / verify (push) Successful in 56s
CI / infrastructure-smoke (push) Successful in 38s
CI / migration-smoke (push) Successful in 1m51s
CI / image-build (push) Successful in 7s
CI / deploy-smoke (same-origin) (push) Successful in 41s
CI / deploy-smoke (split) (push) Successful in 52s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/62
Reviewed-by: The_River <[email protected]>
2026-10-06 04:08:58 +00:00
ekklesiaandClaude Opus 5.5 5abc8be167 chore(release): prepare 0.1.2
CI / verify (pull_request) Successful in 55s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / image-build (pull_request) Successful in 27s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / deploy-smoke (split) (pull_request) Successful in 1m11s
Every workspace package declares 0.1.2, as the 0.1.1 release did for
itself, and the deploy README's build example names it. The tag
v0.1.2 created on 12dca3e carries package.json 0.1.1, which the
instance's assemble.sh refuses ("its tag and package.json disagree"),
and the running platform would report 0.1.1.

0.1.2 carries what the first production deployment found:
- #57: Doctor checks the client-address configuration the API runs;
- #58: storage over HTTP/1.1 (repeat image loads no longer fail with
  ERR_HTTP2_PROTOCOL_ERROR), and storage CORS owned by the proxy;
- #60: CI stacks never outlive their job;
- #56: display-device smoke scenarios;
- #59, #61: ADR 0047 proposed, and the CI documentation corrected for
  Gitea 1.27.2.

templates/module keeps its own version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 22:56:35 -05:00
The_River 12dca3e8a4 Merge pull request 'docs: the server runs Gitea 1.27.2, not 1.24.2; say what it actually honours' (#61) from docs/gitea-1.27-ci-facts into main
CI / verify (push) Successful in 51s
CI / infrastructure-smoke (push) Successful in 34s
CI / migration-smoke (push) Successful in 1m50s
CI / image-build (push) Successful in 7s
CI / deploy-smoke (same-origin) (push) Successful in 45s
CI / deploy-smoke (split) (push) Successful in 45s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/61
Reviewed-by: The_River <[email protected]>
2026-10-06 03:50:31 +00:00
ekklesiaandClaude Opus 5.5 0ec1755279 docs: the server runs Gitea 1.27.2, not 1.24.2; say what it actually honours
CI / verify (pull_request) Successful in 54s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m50s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / deploy-smoke (split) (pull_request) Successful in 1m1s
The CI documentation, the workflow header, the README, ADR 0047 and the
sweep's comment all said the server was Gitea 1.24.2, which ignores
`permissions`, `concurrency` and `timeout-minutes`. The server reports
1.27.2 (GET /api/v1/version).

Each claim is now stated from what is verified on this server:
- `concurrency` is honoured, observed: Ekklesia runs 133 and 135 were
  cancelled when a newer run started for the same pull request;
- `permissions` is supported by 1.27 with Gitea's own scopes, but the
  token's actual authority is not visible through the API and not yet
  proven, so nothing claims the job token is read-only;
- `timeout-minutes` is no longer listed as unsupported, but is not
  verified, and teardown still relies on the stack sweep and a reaper.

Requirement 1 for executing untrusted contributions (upgrade to the
1.26 line or later) is marked done; items 2 and 3, configuring and
proving token authority, remain. ADR 0047 (still Proposed) restates
its "docker run, not a reusable workflow" reasons for 1.27, where
private sources are supported. The reasons still hold without the 1.24
argument.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 22:11:00 -05:00
The_River 874deabea3 Merge pull request 'fix(ci): nothing a CI job starts outlives it, even when the runner kills the job' (#60) from fix/ci-stack-cleanup into main
CI / verify (push) Successful in 55s
CI / infrastructure-smoke (push) Successful in 29s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 7s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / deploy-smoke (split) (push) Successful in 50s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/60
Reviewed-by: The_River <[email protected]>
2026-10-06 02:11:53 +00:00
ekklesiaandClaude Opus 5.5 62951975da fix(ci): nothing a CI job starts outlives it, even when the runner kills the job
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 28s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 48s
CI / deploy-smoke (split) (pull_request) Successful in 53s
A migration-smoke stack (PostgreSQL and SeaweedFS) ran on the shared
runner for two days. Every CI script removes its stack in an EXIT trap,
but a trap never runs when the runner kills the job container: a
cancelled job, or the runner's own time limit (Gitea 1.24 ignores
timeout-minutes). Reproduced: SIGKILL of the infrastructure smoke
mid-run leaves exactly that stack running.

Cleanup now has three layers:
- the script's own trap, unchanged;
- a final `if: always()` step in every job that starts stacks
  (infrastructure-smoke, migration-smoke, image-build, deploy-smoke)
  runs `scripts/ci-sweep.sh --job`, which removes everything that job
  started at any age;
- a first step in each of those jobs runs `scripts/ci-sweep.sh --stale`,
  which removes CI stacks older than three hours: what a killed job left
  before it could clean up.

How stacks are found is defined once, in scripts/lib/ci-project.sh:
- every CI Compose project is named `ekklesia-ci-<run>-<attempt>-<job
  key>-<kind>-<unique>`, and Compose labels its containers, networks and
  volumes with that name;
- the deploy smoke labels everything it starts outside Compose:
  browser, kiosk and driver containers, the kiosk volume, built images;
- images also record when CI built them. An image's own creation time
  can be its base's (BuildKit, metadata-only builds), and the sweep
  removed a brand-new image as "old" until it stopped trusting that.

The job key is per matrix leg, so one topology's cleanup cannot remove
the other's running stack. The sweep never touches anything outside the
ekklesia-ci- prefix, disconnects rather than removes a job container
attached to a stack's network, and refuses --job outside CI. Network
creation times are read as RFC 3339; Docker's default form was
unparseable.

scripts/test-ci-sweep.sh proves all of that against real Docker, and
CI runs it before relying on it. security.md and testing.md document
the rule, and that it complements, not replaces, a runner-side reaper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 02:01:01 +00:00
ekklesiaandClaude Opus 5.5 703d92436b test(ci): every CI stack is cleaned up, even when its job is killed
A migration-smoke stack (PostgreSQL and SeaweedFS) leaked on the shared
runner and ran for two days, until it was removed by hand. Every CI
script already tears its stack down in an EXIT/INT/TERM trap, but a
trap never runs when the runner kills the job container: a cancelled
job, or the runner's own time limit, since Gitea 1.24 ignores
timeout-minutes.

Static checks (tests/ci-scripts.test.ts):
- every stack script names its project through one helper: the reserved
  ekklesia-ci- prefix, run, attempt, job key, and a unique suffix;
- everything the deploy smoke creates outside Compose carries the CI
  label;
- every workflow job that starts stacks sweeps stale CI stacks first,
  and removes its own in a final if: always() step. Each matrix leg has
  its own key, so one cannot remove the other's running stack.

Behaviour against real Docker (scripts/test-ci-sweep.sh):
- --job removes this job's containers, network, volume, image and
  staging directory, and leaves other jobs' and non-CI stacks alone;
- --job disconnects, never removes, a job container on the stack's
  network, and refuses to run outside CI;
- --stale spares young stacks and removes old ones, never anything
  outside the prefix.

Fails today: there is no helper, no sweep, no label and no step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 02:01:01 +00:00
The_River 43911a088d Merge pull request 'docs: propose ADR 0047, a published harness every module runs in CI' (#59) from docs/adr-0047-module-harness into main
CI / verify (push) Successful in 58s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 1m50s
CI / image-build (push) Successful in 6s
CI / deploy-smoke (same-origin) (push) Successful in 40s
CI / deploy-smoke (split) (push) Successful in 49s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/59
Reviewed-by: The_River <[email protected]>
2026-10-06 02:00:39 +00:00
ekklesiaandClaude Opus 5.5 3a2f5d68f6 docs: ADR 0047, nothing the harness starts outlives its job
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 57s
A job the runner kills never runs its own cleanup trap, and the
platform's CI leaked a whole migration stack that way. The harness
follows the platform's three-layer rule:
- everything it starts is named and labeled as a CI stack;
- it cleans up in a trap;
- the template workflow sweeps stale CI stacks before running it, and
  removes its own in a final if: always() step.

The sweep ships in the harness image.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 20:54:38 -05:00
ekklesiaandClaude Opus 5.5 3827841205 docs: propose ADR 0047, a published harness every module runs in CI
CI / verify (pull_request) Successful in 53s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Canceled after 18s
Signage reached production with three defects that appear only inside
Ekklesia in a real browser, and no CI that ran it there. The ADR
proposes:
- a versioned harness image, ekklesia-module-harness:<platform version>,
  built and pushed by the platform's release CI;
- what the harness does: assemble a module into that platform, validate
  and build it, migrate a disposable database, start the split reference
  deployment, and mount every declared UI surface in headless Chromium;
- what fails it: console errors, unexpected 4xx/5xx, unhandled
  rejections, a display soak, and generic no-dead-ends injections;
- testing at the lowest and newest platform versions of a module's
  range;
- extra smoke steps in ekklesia.harness.json, beside the manifest
  rather than in it;
- a later data-access and egress check against declarations;
- a short template workflow that calls the harness by digest with one
  `docker run`. Not a reusable workflow: thinly supported on Gitea
  1.24, and private sources only arrive in 1.26.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 01:50:06 +00:00
The_River 2152eb036a Merge pull request 'fix(deploy): reach storage over HTTP/1.1, and own storage CORS in the proxy' (#58) from fix/storage-delivery-http1-cors into main
CI / verify (push) Successful in 53s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m47s
CI / image-build (push) Successful in 7s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / deploy-smoke (split) (push) Successful in 52s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/58
Reviewed-by: The_River <[email protected]>
2026-10-06 01:39:39 +00:00
ekklesiaandClaude Opus 5.5 e1ac1da416 fix(deploy): reach storage over HTTP/1.1, and own storage CORS in the proxy
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 47s
CI / deploy-smoke (split) (pull_request) Successful in 1m2s
Every reload of a cached files. image failed in the browser with
ERR_HTTP2_PROTOCOL_ERROR. A browser revalidates a cached signed URL
with If-None-Match. Caddy reached SeaweedFS over HTTP/2, and there
SeaweedFS answers with a 304 declaring content-type: application/xml
and content-length: 327 while sending no body. Caddy then logged
"aborting with incomplete response ... unexpected EOF" and reset the
stream. Over HTTP/1.1 SeaweedFS's 304 is clean. Measured layer by layer:
DevTools, curl through Caddy, and curl direct over HTTP/1.1 and HTTP/2.

The storage upstream is now HTTP/1.1 only (`versions 1.1`). Browsers
still reach the proxy over HTTP/2 and HTTP/3.

Delivery-layer CORS is the deployment's, so it no longer depends on
which S3 backend is behind it. SeaweedFS's default reflected any origin
and advertised PUT, POST and DELETE. Now the proxy:
- grants CORS only to the origins that read objects with fetch(): the
  Ekklesia origin, or, when split, the API origin (displays) and the
  Dashboard origin (a module's preview caches images through fetch());
- allows GET and HEAD only, with Vary: Origin;
- answers preflights itself, so they never reach storage;
- strips the object store's own CORS headers.

The deploy README documents the policy, its reasons, and what a
self-run proxy must do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 01:19:17 +00:00
ekklesiaandClaude Opus 5.5 dcbd7613ee test(deploy): load the same image twice over HTTP/2, and pin storage CORS
Two checks on storage delivery through the proxy, in both topologies.

The same signed image, twice, over a real HTTP/2 connection (node:http2;
Node's fetch speaks HTTP/1.1, where the failure does not exist). The
second load is conditional, as a browser's revalidation is, and must be
a clean 304. Today Caddy reaches SeaweedFS over HTTP/2, SeaweedFS's 304
declares Content-Length: 327 and sends no body, and Caddy resets the
stream. A browser reports that as ERR_HTTP2_PROTOCOL_ERROR on every
reload of a cached files. image.

CORS on delivery is the deployment's own:
- exactly the API origin (and the Dashboard origin when split) is
  allowed, with Vary: Origin and nothing else;
- any other origin gets no CORS header;
- a preflight is answered by the proxy (204, GET and HEAD only) and
  never reaches the object store.

Today SeaweedFS reflects every origin with PUT, POST and DELETE, and
answers the preflight itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 01:19:17 +00:00
The_River dbb4648a35 Merge pull request 'fix(deploy): give migrate the API's client-address settings, and say what Doctor checked' (#57) from fix/doctor-client-address into main
CI / verify (push) Successful in 54s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m45s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (same-origin) (push) Successful in 39s
CI / deploy-smoke (split) (push) Successful in 44s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/57
Reviewed-by: The_River <[email protected]>
2026-10-06 01:18:18 +00:00
ekklesiaandClaude Opus 5.5 f1d6501239 fix(deploy): give migrate the API's client-address settings, and say what Doctor checked
CI / verify (pull_request) Successful in 1m51s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 35s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m16s
Doctor checks the client-address configuration of the process it runs
in, and operators run it as `migrate`. The proxy overlay set
trusted-proxy mode on `api` only, so Doctor reported "Direct
client-address mode" for an installation that trusts its proxy.

- compose.proxy.yaml defines the three variables once, as a YAML
  anchor, and applies it to both `api` and `migrate`. Rendered with
  `docker compose config`, `api` is unchanged and `migrate` now
  matches it, keeping NODE_EXTRA_CA_CERTS from the base.
- The example deploy/.env sets
  COMPOSE_FILE=compose.yaml:compose.proxy.yaml, as the instance already
  does. Plain `docker compose run --rm migrate ...` in the README then
  runs with the overlay; it used to read compose.yaml alone.
- Every client-address check in Doctor now says it checked the
  environment Doctor runs with, and how to read the running API's own
  value (`docker compose exec api printenv
  EKKLESIA_CLIENT_ADDRESS_MODE`, confirmed to work in the image).
- The deploy README explains why operator commands need the same files
  as the stack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 00:42:38 +00:00
ekklesiaandClaude Opus 5.5 54ac6ab83f test(deploy): Doctor must check the client-address configuration the API runs
Behind the reference proxy the API runs in trusted-proxy mode, but
compose.proxy.yaml gives those variables to `api` only. The documented
Doctor command runs as `migrate`, so it validated an empty
configuration and reported "Direct client-address mode" for an
installation that trusts its proxy. That is what the first production
installation saw.

Fails today:
- deploy.test.ts: the proxy overlay gives `migrate` no client-address
  environment;
- doctor.test.ts: the check does not say whose configuration it read;
- smoke-deploy.sh: Doctor through the proxy overlay does not report
  trusted-proxy mode (Docker).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 00:42:38 +00:00
The_River fa493365bd Merge pull request 'test(deploy): opt-in display-device scenarios for the split smoke' (#56) from test/m0-display-device-repro into main
CI / verify (push) Successful in 53s
CI / infrastructure-smoke (push) Successful in 22s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (same-origin) (push) Successful in 42s
CI / deploy-smoke (split) (push) Successful in 52s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/56
Reviewed-by: The_River <[email protected]>
2026-10-06 00:24:47 +00:00
ekklesiaandClaude Opus 5.5 f50e8f4201 test(deploy): word the long-watch checks for any watch length
CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 59s
The recycle step's checks spoke of "the realtime recycle" even when
run with a watch shorter than the five-minute connection age, as module
CI does. They now say what they check: live for the whole watch (plus
"across a realtime recycle" when the watch is long enough), and never
remounted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 19:09:51 -05:00
ekklesiaandClaude Opus 5.5 ff8a3e8097 test(deploy): add opt-in display-device scenarios to the split smoke
CI / verify (pull_request) Successful in 53s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 20s
CI / deploy-smoke (same-origin) (pull_request) Successful in 48s
CI / deploy-smoke (split) (pull_request) Successful in 55s
Reproduction harness for what the first production Signage deployment
found. With EKKLESIA_SMOKE_DISPLAY_DEVICE=1, the split smoke starts a
second headless Chromium (the "kiosk") whose profile directory is a
Docker volume. It pairs that kiosk through the real flow, closes it
cleanly, restarts it on the same profile, and then runs injected-failure
scenarios: two pages on one credential, a connectivity blip, one refused
viewer-session request, one refused realtime connection, a cross-origin
image-caching probe, a full realtime recycle, and revocation.

Every DevTools command and page-side request has its own deadline, and
each step has a budget. A step that overruns prints the exact call it
was waiting for. The proxy's "aborting with incomplete response" lines
are listed afterwards by UTC time and path only.

Off by default: several scenarios expect Signage and fail on today's
v0.1.1, by design.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 18:25:12 -05:00
The_River b2efb4b029 Merge pull request 'feat: support split Dashboard and API origins (ADR 0045)' (#54) from feat/split-origins into main
CI / verify (push) Successful in 1m27s
CI / infrastructure-smoke (push) Successful in 22s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 7s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / deploy-smoke (split) (push) Successful in 45s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/54
Reviewed-by: Joey <[email protected]>
2026-10-03 19:53:52 +00:00
ekklesiaandClaude Opus 5.5 f63f80f2f0 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
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]>
2026-10-03 14:47:36 -05:00
Joey cbf5962d04 Merge pull request 'docs: propose ADR 0046, installation-owned branding' (#53) from docs/adr-0046-installation-branding into main
CI / verify (push) Successful in 49s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m47s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (push) Successful in 48s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/53
Reviewed-by: Joey <[email protected]>
2026-10-03 18:54:46 +00:00
Joey 38cf52757d Merge branch 'main' into docs/adr-0046-installation-branding
CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 7s
CI / deploy-smoke (pull_request) Successful in 42s
2026-10-03 18:43:02 +00:00
Joey 5162b4cc77 Merge pull request 'Docs/adr 0045 split origins' (#51) from docs/adr-0045-split-origins into main
CI / verify (push) Successful in 52s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m40s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (push) Successful in 48s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/51
Reviewed-by: Joey <[email protected]>
2026-10-03 18:42:41 +00:00
ekklesiaandClaude Opus 5.5 8ea4d4d2e4 docs: propose ADR 0046, installation-owned branding
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m40s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (pull_request) Successful in 48s
Records a core principle: Ekklesia's branding is never forced on an
installation. Anything a member, visitor, or volunteer can see (titles,
sign-in and auth screens, emails, display and offline screens, error
messages, app names and icons, attribution) is configurable and removable
entirely. With no name configured, wording is neutral, so the default
names nothing and the rule is testable. Internal identifiers, operator
tooling, and developer diagnostics are exempt. Modules follow the same
rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 13:29:11 -05:00
ekklesiaandClaude Opus 5.5 55accd0e65 docs: state exactly what ADR 0045's browser smoke proves
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (pull_request) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 13:29:11 -05:00
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
The_River b642d8fe30 Merge pull request 'fix(ci): isolate concurrent runs on a shared Docker daemon and explain readiness failures' (#52) from fix/ci-concurrent-runs into main
CI / verify (push) Successful in 51s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (push) Successful in 43s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/52
Reviewed-by: The_River <[email protected]>
2026-10-03 18:28:18 +00:00
ekklesiaandClaude Opus 5.5 278e66049f fix(ci): isolate concurrent runs on a shared Docker daemon and explain readiness failures
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 14s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (pull_request) Successful in 41s
PR #51's migration-smoke failed 0.7 s after the build: the suite pinned
PostgreSQL to an ephemeral port but inherited S3_PORT=8333 from
.env.example, so concurrent runs on one runner (Gitea ignores workflow
concurrency) contended for one host port. Compose's own error was
discarded, so the failure said only that the services "did not become
ready".

Isolation, from an audit of every script CI runs:
- test-migrations-integration.sh: S3_PORT=0, like POSTGRES_PORT=0.
- test-migrations-integration.sh and smoke-infrastructure.sh: project names
  gain a random suffix; run id plus PID alone is not unique when job
  containers share low PIDs.
- smoke-deploy.sh: the edge /24 is chosen from those no network on the
  daemon uses, and the first `up` chooses again when a concurrent run wins
  a race for the same subnet (Docker's address-pool overlap), up to five
  times.
- CI image-build: the image tag includes the run id and attempt, so a pull
  request and a push of the same commit cannot remove each other's image.
- Already isolated: every other published port (ephemeral), the TLS
  suite's container and network (random), smoke staging directories and
  image tags (random).

Diagnostics: scripts/lib/compose-diagnostics.sh prints the failed `up`
command's output, `docker compose ps`, and the last 40 log lines of each
service, all redacted (supplied literals, credential-looking NAME=value
pairs, URL user information, Authorization headers) with terminal control
sequences stripped. All three scripts use it at every readiness check; the
infrastructure smoke's unredacted full-log dump is replaced by it.

tests/ci-scripts.test.ts pins ephemeral ports, unique project names, the
subnet choice, the CI image tag, the diagnostics, and the redaction itself
(run through bash).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 12:15:01 -05:00
The_River c737ebb472 Merge pull request 'chore(release): prepare 0.1.1 and accept ADR 0044' (#50) from chore/release-0.1.1 into main
CI / verify (push) Successful in 50s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m47s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (push) Successful in 43s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/50
2026-10-03 16:45:39 +00:00
ekklesiaandClaude Opus 5.5 7cbe4e1b2c chore(release): prepare 0.1.1 and accept ADR 0044
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m46s
CI / image-build (pull_request) Successful in 25s
CI / deploy-smoke (pull_request) Successful in 41s
Bump the root and every platform workspace package from 0.1.0 to 0.1.1, so
the platform reports the version the v0.1.1 tag will carry. Module discovery
checks compatibility.platform against the root version and
compatibility.moduleSdk against @ekklesia/module-sdk; both stay inside every
existing ^0.1.0 range, so no module needs a new range. Everything since v0.1.0
is additive (health endpoints, Dashboard serving, production dependency
declarations, the deployment image and Compose project).

templates/module keeps 0.1.0: it is a module, versioned on its own, and its
manifest, source, and tests carry its own product version.

Mark ADR 0044 Accepted. Slices 1-4 are implemented and proven by CI's image
build and deploy smoke; slice 6 (scheduled backups and the restore
procedure) remains its open item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 10:03:33 -05:00
The_River 481e80118b Merge pull request 'feat(deploy): add the reference deployment Compose project' (#49) from feat/deploy-reference-compose into main
CI / verify (push) Successful in 54s
CI / infrastructure-smoke (push) Successful in 14s
CI / migration-smoke (push) Successful in 1m48s
CI / image-build (push) Successful in 7s
CI / deploy-smoke (push) Successful in 48s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/49
2026-10-02 03:08:08 +00:00
ekklesiaandClaude Opus 5.5 167b4c4f14 feat(deploy): add the reference deployment Compose project
CI / verify (pull_request) Successful in 54s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 27s
CI / deploy-smoke (pull_request) Successful in 42s
Implement ADR 0044's reference deployment as a base Compose file plus
overlays, so an installation that already runs a proxy can use the base
alone.

- compose.yaml: api, postgres, object-storage, and the operator-only
  migrate (profile ops, plan by default). No published port. api and
  migrate run init, read-only, tmpfs /tmp, cap_drop ALL,
  no-new-privileges. postgres is only on the internal data network.
- TLS everywhere from one installation-private CA: PostgreSQL TLS 1.3
  only, with pg_hba rejecting every non-TLS connection, and the API on
  verify-full via EKKLESIA_DATABASE_CA (PEM text). SeaweedFS serves S3
  over HTTPS (ADR 0030 requires https in production), trusted by the
  API through NODE_EXTRA_CA_CERTS. No Core change.
- compose.proxy.yaml + Caddyfile.example: Caddy pinned by digest, ACME
  HTTP-01 (DNS-01 documented), routes /, /api/*, /display/*, /assets/*
  to api and refuses everything else; the storage hostname preserves
  Host. The API trusts exactly the proxy's fixed /32 on an edge network
  whose dynamic range excludes the fixed addresses; Caddy overwrites
  X-Forwarded-For and strips caller-supplied forwarding headers.
- compose.tunnel.yaml: cloudflared pinned by digest, token from its own
  env file; Caddy honors CF-Connecting-IP only from its fixed address.
- Four per-service root-owned env files, examples with no values;
  scripts/init-tls.sh (idempotent, never overwrites a key) and
  scripts/gen-secrets.sh (prints only).
- CI: pnpm deploy:config in ci:verify for all three combinations, and a
  deploy-smoke job (scripts/smoke-deploy.sh) proving readiness, the
  Dashboard, signed storage delivery, the client-address chain, initial
  owner sign-in/out, verify-full database TLS, and Doctor through the
  proxy, against disposable services. tests/deploy.test.ts pins the
  structural guarantees.
- ADR 0044 (still Proposed) amended to match: API-to-storage HTTPS,
  DNS-01 needs a custom Caddy build and tunnel-only needs DNS-01, the
  tunnel's origin TLS settings live in the Cloudflare dashboard, port
  80 serves HTTP-01, and secrets are one env file per service.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 20:59:38 -05:00
The_River b593abe7a5 Merge pull request 'Feat/deploy container image' (#48) from feat/deploy-container-image into main
CI / verify (push) Successful in 49s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m47s
CI / image-build (push) Successful in 7s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/48
2026-10-02 01:16:21 +00:00
ekklesiaandClaude Opus 5.5 e12ef10a65 docs(deploy): explain the warm-cache image size variance
CI / verify (pull_request) Successful in 48s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m45s
CI / image-build (pull_request) Successful in 33s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 20:06:08 -05:00
ekklesiaandClaude Opus 5.5 4468335e8f build(deploy): run pnpm boundaries in the image build stage
A module that imports a package at run time without declaring it a
production dependency now fails the image build instead of the API's
startup, since the runtime receives production dependencies only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 20:01:02 -05:00
ekklesiaandClaude Opus 5.5 756e9b821c feat(deploy): add the production container image
Add deploy/Dockerfile and .dockerignore (ADR 0044): one image for the
api service and every operator-run CLI, built from an already-assembled
workspace (the platform plus whatever modules/<id> the context holds).

- node:24.18.0-trixie-slim pinned by index digest; pnpm via Corepack at
  the root packageManager version.
- Build stage: frozen install, modules:validate, migrations:validate,
  build. Runtime: production-only node_modules plus every dist/, core
  migrations, and each module directory, in the repository layout the
  API and CLIs resolve paths from; root-owned, run as node.
- NODE_ENV=production and EKKLESIA_API_HOST=0.0.0.0 inside the image
  only; the platform default stays 127.0.0.1. Configuration comes only
  from the environment; no .env enters the context or any layer.
- HEALTHCHECK on /api/v1/health/ready with Node's fetch; OCI labels for
  source, revision, and version.
- CI builds the image without modules: build only, no credentials, no
  push.
- deploy/README.md documents the image, the read-only production
  posture (verified), init: true, and the exact CLI commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 20:00:43 -05:00
The_River ef1383d080 Merge pull request 'fix(packages): declare runtime imports as production dependencies' (#47) from fix/module-sdk-runtime-dependencies into main
CI / verify (push) Successful in 49s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 1m46s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/47
2026-10-02 00:58:38 +00:00
ekklesiaandClaude Opus 5.5 fc422b6d8d fix(packages): declare runtime imports as production dependencies
CI / verify (pull_request) Successful in 47s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m45s
@ekklesia/module-sdk imports drizzle-orm at run time but declared it
only as a peer and a development dependency. A workspace package is
linked into its consumers as a directory, so its imports resolve from
its own node_modules, where a consumer never satisfies a peer; a
production install omits development dependencies. In the production
image this made every module with a schema fail: the API exited at
startup and Doctor reported NOT READY.

- Move drizzle-orm to module-sdk's dependencies, keeping the peer.
- Move react to @ekklesia/ui's dependencies, keeping the peer. The new
  guard flags it the same way; it is bundled at build time today, so
  it was not a live failure. Both lockfile entries keep the identical
  resolution, so nothing new is installed and the shell bundles still
  contain one React.
- Add the boundary rule runtime-imports-are-production-dependencies:
  code a package ships (src/, bin/, excluding tests and .d.ts) must
  declare every runtime import in dependencies or optionalDependencies.
  Type-only imports are exempt; .tsx/.jsx implies react (automatic
  JSX runtime). Fixtures cover peer-only, dev-only, JSX, undeclared,
  and the accepted cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 19:29:35 -05:00
The_River d5d4928b9f Merge pull request 'feat(api): serve the built Dashboard from the API origin' (#46) from feat/api-serve-dashboard into main
CI / verify (push) Successful in 46s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m44s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/46
2026-10-01 23:28:56 +00:00
ekklesiaandClaude Opus 5.5 a12cbe9600 feat(api): serve the built Dashboard from the API origin
CI / verify (pull_request) Successful in 46s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m44s
Serve surfaces/shell's built Dashboard from apps/api on the same origin
as /api (ADR 0044), exactly as the display host is served: a closed set
of files read once at startup, never a static directory, never a path
built from request input.

- The shell exports @ekklesia/shell/dashboard-assets (the exact built
  index.html and script URLs), emits the script at
  assets/ekklesia-dashboard/app.js, and index.html names that absolute
  path so dev and production serve the same URLs.
- GET / and GET /assets/ekklesia-dashboard/app.js answer from a frozen
  snapshot: no-cache with a strong ETag and 304 on match. Source maps
  are never served; every other path is the ordinary sanitized 404.
- The page sets nosniff, X-Frame-Options: DENY, Content-Security-Policy:
  frame-ancestors 'none' (that directive only), and Referrer-Policy:
  no-referrer, on 200 and 304 alike.
- A missing or empty shell build fails startup with a fixed
  DashboardHostAssetError before any module code is imported.
- The If-None-Match comparison moves to conditional-request.ts, shared
  with the display host routes.
- The emailed-link token stripping moves from browser/main.tsx into
  consumeAuthLanding (location and history injected) so it is tested;
  behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 17:54:55 -05:00
The_River 220b7b152a Merge pull request 'feat(api): add unauthenticated health endpoints' (#45) from feat/api-health-endpoints into main
CI / verify (push) Successful in 45s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m42s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/45
2026-10-01 22:25:02 +00:00
ekklesiaandClaude Opus 5.5 aaec9789c4 feat(api): add unauthenticated health endpoints
CI / verify (pull_request) Successful in 45s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m42s
Add GET /api/v1/health/live and GET /api/v1/health/ready per ADR 0044.

- live returns a fixed 200 {"status":"live"} whenever the process serves.
- ready returns 200 {"status":"ready"} only when module startup has
  completed (and shutdown has not begun) and a database ping succeeds
  within 2s; otherwise a fixed 503 {"code":"NOT_READY"}.
- The ping runs on the shared pool as one simple-protocol message with
  a transaction-local statement_timeout, so PostgreSQL cancels a stalled
  statement; the pool discards the connection on failure.
- At most one ping is ever unsettled: probes share its outcome and, once
  its deadline passes, answer 503 immediately until it settles.
- Both routes set Cache-Control: no-store, accept GET only (405 with
  Allow: GET otherwise, HEAD included), never resolve a principal, and
  are mounted in app.ts beside, never inside, /api/v1/modules/.
- createApiApp gains a required `health` option; startApi supplies it
  and clears module readiness when shutdown begins.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 16:47:01 -05:00
115 changed files with 8355 additions and 199 deletions
+25
View File
@@ -0,0 +1,25 @@
# Build context for deploy/Dockerfile (ADR 0044). The image installs its own dependencies with
# `pnpm install --frozen-lockfile` and builds its own outputs, so nothing installed or built on the
# host may enter the context, and no secret or environment file may enter any layer.
# Host-installed dependencies and build outputs: the image produces its own.
**/node_modules
**/dist
**/.turbo
**/coverage
**/*.tsbuildinfo
# Environment and secret material. Configuration reaches the container only through its runtime
# environment; `.env.example` is excluded too, since nothing in the image reads it.
**/.env
**/.env.*
# Repository and tooling metadata the image never reads.
.git
.github
.gitea
.forgejo
.vscode
.idea
**/*.log
**/.DS_Store
+127 -12
View File
@@ -1,18 +1,13 @@
name: CI
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
# correct on a supported release and on GitHub Actions, but on this server they are inert:
# The server runs Gitea 1.27.2 (docs/development/security.md, "What this server honours"):
#
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
# is upgraded and configured, the token's authority is whatever the server grants — not what
# this file requests.
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
# * `concurrency:` is honoured: a newer push to a pull request cancels the run it supersedes, so a
# cancelled run's own cleanup is never guaranteed (see the CI stack sweep in each stack job).
# * `permissions:` is supported by 1.27 with Gitea's own scopes, but what the job token can do on
# this server is not yet proven, so nothing here relies on it being read-only.
# * `timeout-minutes:` is no longer listed as unsupported, but is not verified here either;
# teardown relies on the stack sweep and the runner's own reaper.
#
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
# itself. See docs/development/security.md.
@@ -63,6 +58,9 @@ jobs:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 10
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: infrastructure-smoke
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -72,13 +70,29 @@ jobs:
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
- name: Prove the sweep removes exactly what it should, against real Docker resources
run: bash scripts/test-ci-sweep.sh
- name: Exercise PostgreSQL and S3 infrastructure
run: bash scripts/smoke-infrastructure.sh
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
migration-smoke:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: migration-smoke
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -88,6 +102,11 @@ jobs:
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@@ -103,3 +122,99 @@ jobs:
- name: Exercise transactional migrations against PostgreSQL
run: pnpm test:migrations:integration
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
image-build:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 20
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: image-build
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
# has no buildx plugin.
DOCKER_BUILDKIT: "1"
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
# The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image
# builds, validates, and assembles with zero modules. Build only — no registry login, no
# credentials, no push, no tag anywhere but this runner's local image store.
# Tagged per run and attempt, not only per commit: a pull request and a push of the same commit
# can build at once on one runner's Docker daemon, and neither may remove the other's image.
# Labeled like every CI stack, so this job's final step removes it even if the build is killed.
- name: Build the production image without modules
shell: bash
run: |
source scripts/lib/ci-project.sh
docker build \
--file deploy/Dockerfile \
--build-arg REVISION="$GITHUB_SHA" \
--label "${EKKLESIA_CI_LABEL}=$(ci_project_name image)" \
--label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" \
.
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
deploy-smoke:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 30
# Both topologies (ADR 0045): same-origin, the default every installation gets, and split, which
# adds a test-only module and drives a real headless browser across the two origins.
strategy:
fail-fast: false
matrix:
topology:
- same-origin
- split
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: deploy-smoke-${{ matrix.topology }}
DOCKER_BUILDKIT: "1"
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
# The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
# database TLS through the proxy. Every secret is generated for this run and discarded with
# it; nothing is pushed, published, or logged in to.
- name: Smoke-test the reference deployment (${{ matrix.topology }})
run: bash scripts/smoke-deploy.sh
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
+4 -3
View File
@@ -218,9 +218,10 @@ undeployed state, and the launch checklist in a single place. **CI is accepted a
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
execute on it automatically. What remains is operational and cannot be proven from this repository —
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
configuring and proving job-token authority server-side (the server now runs Gitea 1.27.2, which
honours `concurrency` and documents `permissions`, but neither token limits nor `timeout-minutes` are
verified on it yet), enforcing timeout and teardown in a trusted provisioner/reaper, and proving
disposable runner isolation. Those are
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
gates a **public launch**; moving future public contribution workflows to a service with isolated
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/api",
"version": "0.1.0",
"version": "0.1.2",
"private": true,
"type": "module",
"files": [
+27
View File
@@ -27,10 +27,13 @@ import type { Context, MiddlewareHandler } from "hono";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
import { createDashboardRoutes } from "./dashboard-routes.js";
import { createDeviceRoutes } from "./device-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js";
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
@@ -137,6 +140,20 @@ export interface CreateApiAppOptions {
* presentation only — nothing reachable through this store influences discovery, dependency
* ordering, module startup, or any authorization decision. */
readonly dashboard: DashboardStore;
/**
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
* shell build fails startup instead of leaving this origin answering `/` with a 404. See
* `./dashboard-host-routes.js`.
*/
readonly dashboardHostAssets: DashboardHostAssets;
/**
* The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than
* defaulted, like every other dependency here: a composition that cannot reach a database says so
* with a check that rejects, never falls through to an implicitly healthy one. `checkDatabase` is
* the bare query; the routes themselves bound and serialize it. See `./health-routes.js`.
*/
readonly health: ApiHealthOptions;
readonly runtime: EkklesiaIdentityRuntime;
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
@@ -341,6 +358,16 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
}
});
// Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below,
// it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules
// own entirely. See `./health-routes.js`.
app.route("/", createHealthRoutes(options.health));
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
// `./dashboard-host-routes.js`.
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
app.route(
"/",
createGrantsRoutes({
+16
View File
@@ -0,0 +1,16 @@
/**
* RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition, shared by the host-owned
* asset routes that answer `304` against a strong validator they computed from their own bytes.
*/
export function etagMatches(headerValue: string | undefined, etag: string): boolean {
if (headerValue === undefined) {
return false;
}
if (headerValue.trim() === "*") {
return true;
}
return headerValue.split(",").some((candidate) => {
const value = candidate.trim();
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
});
}
+16
View File
@@ -200,3 +200,19 @@ export function createApiRuntimeConfig(
storage,
});
}
/**
* The exact browser origins the API trusts: CORS on `/api/auth/*` and `/api/v1/*`, and every
* mutation route's origin check (ADR 0045).
*
* The configured trusted origins plus the identity base URL's own origin, which is where the API
* lives. Better Auth already trusts its base URL implicitly; this makes the API's own checks agree,
* so a page the API itself serves (the display host, and the Dashboard in the same-origin topology)
* passes without an operator listing the API's origin. Order is preserved and duplicates removed;
* every entry is already a canonical origin.
*/
export function apiTrustedOrigins(
identity: Pick<IdentityConfig, "baseUrl" | "trustedOrigins">,
): readonly string[] {
return Object.freeze([...new Set([...identity.trustedOrigins, identity.baseUrl])]);
}
+108
View File
@@ -0,0 +1,108 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
/**
* The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
*
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
* from the exact URLs the surface package exports. Request input is never involved in this read, the
* package is never exposed as a static directory, and the source map beside the script is never
* read. Text rather than a mutable `Buffer` keeps the frozen snapshot a genuinely immutable value
* every response can reuse.
*/
export interface DashboardHostAssets {
readonly html: string;
readonly htmlEtag: string;
readonly script: string;
readonly scriptEtag: string;
}
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
* location, and machine path are intentionally not retained or surfaced. */
export class DashboardHostAssetError extends Error {
readonly code = "DASHBOARD_HOST_ASSET_UNAVAILABLE" as const;
constructor() {
super("The Dashboard host browser assets are unavailable.");
this.name = "DashboardHostAssetError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function strongEtag(text: string): string {
return `"${createHash("sha256").update(text).digest("base64url")}"`;
}
/**
* Captures one immutable, internally consistent Dashboard asset snapshot.
*
* Each strong validator is derived from the exact text in the same snapshot, so bytes from one build
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
* valid page that can never render anything.
*/
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
if (html.trim().length === 0 || script.trim().length === 0) {
throw new DashboardHostAssetError();
}
return Object.freeze({
html,
htmlEtag: strongEtag(html),
script,
scriptEtag: strongEtag(script),
});
}
/**
* The exact element the built Dashboard page carries for its API origin (ADR 0045), empty. The shell
* reads the filled-in value at run time; empty means the page's own origin.
*/
export const DASHBOARD_API_ORIGIN_PLACEHOLDER = '<meta content="" name="ekklesia-api-origin" />';
function isCanonicalHttpOrigin(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && url.origin === value;
} catch {
return false;
}
}
/**
* Writes the API's own origin into the Dashboard page, once, before the page's `ETag` is computed.
*
* The origin is the identity base URL's, which is where `/api` lives in either topology: the page's
* own origin when the Dashboard and the API share one, the API hostname when they are split. The
* built page must carry exactly one empty placeholder; anything else is a shell build this API does
* not understand, and startup fails rather than serving a page that might send credentials somewhere
* unintended. The value is escaped for an attribute even though a canonical origin cannot contain
* any character that needs it.
*/
export function renderDashboardPage(html: string, apiOrigin: string): string {
const parts = html.split(DASHBOARD_API_ORIGIN_PLACEHOLDER);
if (parts.length !== 2 || !isCanonicalHttpOrigin(apiOrigin)) {
throw new DashboardHostAssetError();
}
const escaped = apiOrigin
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
}
/** Loads only the two surface-declared files, and renders the API origin into the page. A missing,
* unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers
* `/` with a 404 or a page that cannot find its API. */
export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> {
try {
// Sequential deliberately, as for the display host: diagnostics stay deterministic and no
// rejected sibling read is left running after this function has already failed.
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script);
} catch {
throw new DashboardHostAssetError();
}
}
+123
View File
@@ -0,0 +1,123 @@
import { Hono } from "hono";
import { etagMatches } from "./conditional-request.js";
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
/**
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
* `/api/v1/modules/<module-id>`.
*/
export const DASHBOARD_HOST_PAGE_PATH = "/";
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
/**
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
* shell emits fixed, unhashed file names, so no response here may be cached as `immutable`: a
* rebuilt Dashboard must reach a browser on its next load, and an unchanged one costs a `304`.
*/
const DASHBOARD_HOST_CACHE_CONTROL = "no-cache";
/**
* Headers the Dashboard page carries on every answer, including a `304`, so a revalidated cache
* entry never keeps an older set.
*
* The page holds the platform sign-in form, so it refuses to be framed by anyone — `X-Frame-Options`
* for older browsers, and CSP `frame-ancestors`, the one directive a policy here carries until the
* full Content-Security-Policy is decided separately. `no-referrer` keeps the page's URL, which can
* briefly hold an emailed reset token before the shell strips it, out of every request it makes.
*/
const DASHBOARD_PAGE_SECURITY_HEADERS = Object.freeze({
"Content-Security-Policy": "frame-ancestors 'none'",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
});
/** A composition error, detected before any Dashboard route is registered. Carries no file path,
* package location, or asset content. */
export class DashboardHostConfigurationError extends Error {
constructor() {
super("The Dashboard host configuration is inconsistent.");
this.name = "DashboardHostConfigurationError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/** Copies and validates injected assets before registering any route, as the display host does, so
* a composition can never pair bytes with a validator computed from different bytes. */
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
try {
const snapshot = createDashboardHostAssets(input.html, input.script);
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
throw new DashboardHostConfigurationError();
}
return snapshot;
} catch {
throw new DashboardHostConfigurationError();
}
}
function fixedAsset(
ifNoneMatch: string | undefined,
body: string,
etag: string,
contentType: string,
extraHeaders: Readonly<Record<string, string>>,
): Response {
if (etagMatches(ifNoneMatch, etag)) {
return new Response(null, {
headers: { ...extraHeaders, "Cache-Control": DASHBOARD_HOST_CACHE_CONTROL, ETag: etag },
status: 304,
});
}
return new Response(body, {
headers: {
"X-Content-Type-Options": "nosniff",
...extraHeaders,
"Cache-Control": DASHBOARD_HOST_CACHE_CONTROL,
"Content-Type": contentType,
ETag: etag,
},
status: 200,
});
}
/**
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
* default same-origin shape.
*
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
* the sign-in form until `/api/v1/principal` says otherwise.
*/
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
const snapshot = snapshotAssets(assets);
const routes = new Hono();
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
fixedAsset(
c.req.header("if-none-match"),
snapshot.html,
snapshot.htmlEtag,
"text/html; charset=utf-8",
DASHBOARD_PAGE_SECURITY_HEADERS,
),
);
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
fixedAsset(
c.req.header("if-none-match"),
snapshot.script,
snapshot.scriptEtag,
"text/javascript; charset=utf-8",
{},
),
);
return routes;
}
+23
View File
@@ -26,11 +26,31 @@ export interface ApiDatabaseHandle {
readonly module: ModuleDatabase;
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
/**
* One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1`
* runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL
* cancels a stalled statement itself rather than the caller merely abandoning it; the pool's
* session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements
* travel as one simple-protocol message, so the check costs a single round trip. On any failure
* the pool discards that connection instead of returning it, so a transaction left open by a
* cancelled statement never reaches another caller. Rejects with whatever the driver raised; no
* caller may surface it.
*/
readonly ping: (statementTimeoutMs: number) => Promise<void>;
/** Closes the one shared pool exactly once, regardless of how many typed views were created
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
readonly close: () => Promise<void>;
}
function pingStatement(statementTimeoutMs: number): string {
// Interpolated rather than bound: neither SET nor a multi-statement simple query accepts
// parameters. Only a positive safe integer can ever reach the SQL text.
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) {
throw new RangeError("The ping statement timeout must be a positive integer.");
}
return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`;
}
/**
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
* authorization schemas plus the module database facade: one validated, timeout-bounded
@@ -58,5 +78,8 @@ export function createApiDatabase(
module: createModuleDatabaseView(pool),
moduleConfiguration: createModuleConfigurationView(pool),
moduleLifecycle: createModuleLifecycleView(pool),
ping: async (statementTimeoutMs: number) => {
await pool.query(pingStatement(statementTimeoutMs));
},
});
}
+121
View File
@@ -0,0 +1,121 @@
import { Hono } from "hono";
/**
* The longest a readiness probe waits on the database before answering `503` (ADR 0044). Short
* enough for a Compose health check or an external monitor to treat a stall as "not ready" rather
* than as a hung probe, and well inside the shared pool's own 10s connection and operation
* timeouts, which bound the underlying query independently.
*/
export const READINESS_DATABASE_TIMEOUT_MS = 2_000;
export const HEALTH_LIVE_PATH = "/api/v1/health/live";
export const HEALTH_READY_PATH = "/api/v1/health/ready";
export interface ApiHealthOptions {
/**
* One trivial database round trip. Resolves when the database answered; rejects on any failure.
* The rejection is never read — not its message, code, or cause — so nothing a driver says can
* reach a response. The route bounds the wait and never overlaps two calls; this function only
* has to perform the query.
*/
readonly checkDatabase: () => Promise<void>;
/** Whether module startup has completed. `false` again from the moment shutdown begins, so a
* draining instance stops reporting ready while it finishes in-flight requests. */
readonly isModuleStartupComplete: () => boolean;
}
const LIVE_BODY = Object.freeze({ status: "live" });
const READY_BODY = Object.freeze({ status: "ready" });
const NOT_READY_BODY = Object.freeze({ code: "NOT_READY" });
/**
* Wraps `checkDatabase` so at most one call is ever unsettled, and no probe waits longer than
* `timeoutMs`.
*
* Every probe arriving while a check is unsettled shares that check's outcome, including after its
* deadline has passed: the deadline resolves the shared outcome to `false`, so those later probes
* answer immediately. A new check starts only once the previous call has actually settled. A timed-
* out query still occupies a pool connection until the database or the pool's own timeouts end it,
* so starting a fresh query on every probe would let a stalled database drain the pool one health
* check at a time.
*/
function createBoundedDatabaseCheck(
checkDatabase: () => Promise<void>,
timeoutMs: number,
): () => Promise<boolean> {
let inFlight: Promise<boolean> | undefined;
return () => {
if (inFlight !== undefined) {
return inFlight;
}
let timer: ReturnType<typeof setTimeout> | undefined;
// The executor runs `checkDatabase` synchronously, so a synchronous throw is a rejection like
// any other rather than an exception escaping the route.
const settled = new Promise<void>((resolve) => resolve(checkDatabase())).then(
() => true,
() => false,
);
const outcome = Promise.race([
settled,
new Promise<false>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
}),
]);
inFlight = outcome;
void settled.then(() => {
clearTimeout(timer);
inFlight = undefined;
});
return outcome;
};
}
/** The same fixed refusal `./module-realtime-routes.js` uses: Hono lets a GET handler answer HEAD,
* and these routes accept GET alone. */
function methodNotAllowed(): Response {
return new Response(null, {
headers: { Allow: "GET", "Cache-Control": "no-store" },
status: 405,
});
}
/**
* Unauthenticated liveness and readiness (ADR 0044), for Compose health checks and external
* monitoring. Neither route resolves a principal or reads a cookie, and both return fixed bodies
* that name no module, version, error, or connection detail; `pnpm doctor` remains the detailed
* operator report. They live under `/api/v1/health/`, a sibling of the `/api/v1/modules/` prefix that
* installed modules own entirely, so no module route can shadow them.
*/
export function createHealthRoutes(options: ApiHealthOptions): Hono {
const checkDatabase = createBoundedDatabaseCheck(
options.checkDatabase,
READINESS_DATABASE_TIMEOUT_MS,
);
const routes = new Hono();
routes.all(HEALTH_LIVE_PATH, (c) => {
if (c.req.method !== "GET") {
return methodNotAllowed();
}
c.header("Cache-Control", "no-store");
return c.json(LIVE_BODY, 200);
});
routes.all(HEALTH_READY_PATH, async (c) => {
if (c.req.method !== "GET") {
return methodNotAllowed();
}
c.header("Cache-Control", "no-store");
if (!options.isModuleStartupComplete()) {
return c.json(NOT_READY_BODY, 503);
}
const databaseReady = await checkDatabase();
// Checked again afterwards: shutdown may have begun while the query was in flight.
return databaseReady && options.isModuleStartupComplete()
? c.json(READY_BODY, 200)
: c.json(NOT_READY_BODY, 503);
});
return routes;
}
+1 -14
View File
@@ -6,6 +6,7 @@ import {
getAuthorizedPrincipal,
requirePermission,
} from "./authorization-middleware.js";
import { etagMatches } from "./conditional-request.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
export {
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
@@ -100,20 +101,6 @@ function displayFailure(c: Context): Response {
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
}
/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */
function etagMatches(headerValue: string | undefined, etag: string): boolean {
if (headerValue === undefined) {
return false;
}
if (headerValue.trim() === "*") {
return true;
}
return headerValue.split(",").some((candidate) => {
const value = candidate.trim();
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
});
}
/** Copies and validates injected assets before registering any route. Production's loader already
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
* bytes with a stale validator. */
+23 -2
View File
@@ -44,10 +44,12 @@ import {
createClientAddressResolver,
wrapFetchWithClientAddress,
} from "./client-address.js";
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js";
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { withDevicePrincipals } from "./device-principal.js";
import { loadDisplayHostAssets } from "./display-host-assets.js";
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
import { prepareStartupModuleInventory } from "./platform-permissions.js";
const connectionTimeoutMs = 10_000;
@@ -542,9 +544,15 @@ export async function startApi(
// starts and application composition succeeds.
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
try {
displayHostAssets =
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
// so a missing shell build fails here, before any module code is imported.
// The page learns its API origin from the API that serves it, so one build serves either
// topology (ADR 0045).
dashboardHostAssets = await loadDashboardHostAssets(config.identity.baseUrl);
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
@@ -576,6 +584,10 @@ export async function startApi(
throw error;
}
// Set by the first `shutdown()` call, before anything is drained or stopped, so readiness turns
// `503` while the instance still finishes in-flight requests.
let shuttingDown = false;
let app: Hono;
try {
app = createApiApp({
@@ -620,6 +632,13 @@ export async function startApi(
},
// A typed view over the same single pool as identity, authorization, and the module facade.
dashboard: createDashboardStore(databaseHandle.dashboard),
dashboardHostAssets,
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
health: {
checkDatabase: () => databaseHandle.ping(READINESS_DATABASE_TIMEOUT_MS),
isModuleStartupComplete: () => !shuttingDown,
},
devices: {
// `Secure` follows the deployment's own scheme, read from the already-validated identity
// base URL rather than from any request. Production is https by configuration policy, so a
@@ -631,7 +650,8 @@ export async function startApi(
// Principal resolution, extended with managed devices. A human session still wins, and this
// is the only place a device credential ever becomes a principal.
runtime: withDevicePrincipals({ devices: deviceStore, runtime }),
trustedOrigins: config.identity.trustedOrigins,
// The configured origins plus the API's own (ADR 0045).
trustedOrigins: apiTrustedOrigins(config.identity),
});
} catch (error) {
await safeStopModuleRuntime(moduleRuntime);
@@ -677,6 +697,7 @@ export async function startApi(
let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => {
if (!shutdownPromise) {
shuttingDown = true;
shutdownPromise = (async () => {
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
// completes: an in-flight module command may still commit and publish its resulting state.
+113 -1
View File
@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { request as httpRequest } from "node:http";
import net from "node:net";
import { tmpdir } from "node:os";
@@ -26,7 +26,19 @@ import {
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest";
import type { ClientAddressConfigInput } from "../src/client-address.js";
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
import { createApiRuntimeConfig } from "../src/config.js";
import { createApiDatabase } from "../src/database.js";
import { DASHBOARD_API_ORIGIN_PLACEHOLDER } from "../src/dashboard-host-assets.js";
import {
DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
} from "../src/dashboard-host-routes.js";
import {
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.js";
import {
MODULE_UI_CACHE_CONTROL,
MODULE_UI_CONTENT_TYPE,
@@ -2977,6 +2989,106 @@ describe.sequential("live API — published module UI artifact", () => {
});
});
describe.sequential("live API — the built Dashboard on the API's own origin", () => {
it("serves the built shell's index.html at /, with only its API origin written in, and its exact script, and nothing beside them", async () => {
const database = await createDisposableDatabase("dashboard_host");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url);
try {
const origin = apiOrigin(running);
const builtHtml = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
const builtScript = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
const page = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`);
const html = await page.text();
expect(page.status).toBe(200);
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(page.headers.get("cache-control")).toBe("no-cache");
expect(page.headers.get("x-frame-options")).toBe("DENY");
expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
expect(page.headers.get("referrer-policy")).toBe("no-referrer");
// The built page carries one empty API-origin element; the API fills in its own origin
// (the identity base URL) and changes nothing else (ADR 0045).
expect(builtHtml.split(DASHBOARD_API_ORIGIN_PLACEHOLDER)).toHaveLength(2);
expect(html).toBe(
builtHtml.replace(
DASHBOARD_API_ORIGIN_PLACEHOLDER,
`<meta content="${identityBaseUrl}" name="ekklesia-api-origin" />`,
),
);
expect(html).toContain('id="ekklesia-host"');
expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`);
const revalidated = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`, {
headers: { "if-none-match": page.headers.get("etag") ?? "" },
});
expect(revalidated.status).toBe(304);
const script = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}`);
expect(script.status).toBe(200);
expect(script.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
expect(await script.text()).toBe(builtScript);
// The source map sits beside the script on disk and is never published.
const sourceMap = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}.map`);
expect(sourceMap.status).toBe(404);
expect(await sourceMap.json()).toEqual({ code: "NOT_FOUND" });
} finally {
await running.shutdown();
}
});
});
describe.sequential("live API — health", () => {
it("answers liveness and readiness with 200 against a migrated disposable PostgreSQL", async () => {
const database = await createDisposableDatabase("health");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url);
try {
const origin = apiOrigin(running);
const live = await fetch(`${origin}${HEALTH_LIVE_PATH}`);
expect(live.status).toBe(200);
expect(live.headers.get("cache-control")).toBe("no-store");
expect(await live.json()).toEqual({ status: "live" });
const ready = await fetch(`${origin}${HEALTH_READY_PATH}`);
expect(ready.status).toBe(200);
expect(ready.headers.get("cache-control")).toBe("no-store");
expect(await ready.json()).toEqual({ status: "ready" });
} finally {
await running.shutdown();
}
});
it("pings through the shared pool, and PostgreSQL itself cancels a statement that outlives the ping's transaction-local timeout", async () => {
const database = await createDisposableDatabase("health_ping");
const handle = createApiDatabase(database.url);
try {
await expect(handle.ping(READINESS_DATABASE_TIMEOUT_MS)).resolves.toBeUndefined();
} finally {
await handle.close();
}
// The ping's exact statement shape, with a statement that cannot finish inside its timeout: the
// server cancels it (query_canceled) long before the sleep would end, on a client that has no
// client-side timeout of its own.
const client = new Client({ connectionString: database.url });
await client.connect();
try {
const startedAt = Date.now();
await expect(
client.query(
"BEGIN READ ONLY; SET LOCAL statement_timeout = 50; SELECT pg_sleep(5); COMMIT",
),
).rejects.toMatchObject({ code: "57014" });
expect(Date.now() - startedAt).toBeLessThan(2_000);
} finally {
await client.end();
}
});
});
describe.sequential("live API — public signup is disabled by default", () => {
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
const database = await createDisposableDatabase("signup_disabled");
+150
View File
@@ -3,11 +3,16 @@ import {
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
moduleIdSchema,
parseEkklesiaUserId,
parsePermissionId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
CORE_PERMISSION_DECLARATIONS,
CORE_PERMISSION_REGISTRY,
createPermissionRegistry,
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
@@ -16,6 +21,7 @@ import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type ModuleUiPublicationRegistry,
StagedModuleRealtimeRegistry,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
import {
@@ -25,6 +31,8 @@ import {
} from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
@@ -104,6 +112,8 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -121,6 +131,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -355,6 +367,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -378,6 +392,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -403,6 +419,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -430,6 +448,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -457,6 +477,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -481,6 +503,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -507,6 +531,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -530,6 +556,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -555,6 +583,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -587,6 +617,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -620,6 +652,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -658,6 +692,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -678,6 +714,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -700,6 +738,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -721,6 +761,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -740,6 +782,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -766,6 +810,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -795,6 +841,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
runtime: fakeRuntime(),
@@ -827,6 +875,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({
evaluator: { authorize: evaluatorAuthorize },
@@ -850,6 +900,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -864,3 +916,101 @@ describe("Ekklesia API Hono application", () => {
});
});
});
describe("split Dashboard and API origins (ADR 0045)", () => {
const moduleId = moduleIdSchema.parse("announcements");
const permission = parsePermissionId("announcements.slideshow.view");
const endpoint = "/api/v1/module-realtime/announcements/state";
/** The full application with one live channel, a signed-in caller, and an evaluator that allows
* it: the same composition a Dashboard on another origin reaches. */
function realtimeApp() {
const staged = new StagedModuleRealtimeRegistry();
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
registration.realtime.channel({ channel: "state", permission });
registration.commit();
staged.publisherFor(moduleId).publish("state", { mode: "on" } as never);
const moduleRealtime = staged.publish();
const app = createApiAppWithModules({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
moduleRealtime,
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: true }) },
registry: createPermissionRegistry([
...CORE_PERMISSION_DECLARATIONS,
{
baseline: "restricted",
description: "View the announcement slideshow.",
id: permission,
},
]),
}),
runtime: fakeRuntime({
resolvePrincipal: vi
.fn()
.mockResolvedValue(
createAuthenticatedPrincipal(
parseEkklesiaUserId("88888888-8888-4888-8888-888888888888"),
),
),
}),
trustedOrigins: [trustedOrigin],
});
return { app, moduleRealtime };
}
it("lets the Dashboard's origin open a credentialed realtime stream", async () => {
const { app, moduleRealtime } = realtimeApp();
const response = await app.request(endpoint, {
headers: { cookie: "session=1", origin: trustedOrigin },
});
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
expect(response.headers.get("vary")).toContain("Origin");
await response.body?.cancel();
moduleRealtime.closeSubscriptions();
});
it("gives an untrusted origin's realtime request no CORS permission to read the stream", async () => {
const { app, moduleRealtime } = realtimeApp();
const response = await app.request(endpoint, {
headers: { cookie: "session=1", origin: untrustedOrigin },
});
expect(response.headers.get("access-control-allow-origin")).toBeNull();
await response.body?.cancel();
moduleRealtime.closeSubscriptions();
});
it("allows only Content-Type beyond the CORS-safelisted request headers, and exposes none", async () => {
const app = createAuthWiringApp(fakeRuntime());
const response = await app.request("/api/v1/principal", {
headers: {
"access-control-request-headers": "content-type, x-module-custom",
"access-control-request-method": "PATCH",
origin: trustedOrigin,
},
method: "OPTIONS",
});
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("access-control-allow-headers")?.toLowerCase()).toBe(
"content-type",
);
expect(response.headers.get("access-control-expose-headers")).toBeNull();
});
});
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js";
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
const validSecret = "correct-horse-battery-staple-0123456789";
@@ -337,3 +337,24 @@ describe("API runtime configuration", () => {
expect(publicText).not.toContain("db.internal.example");
});
});
describe("the API's trusted browser origins (ADR 0045)", () => {
it("adds the API's own origin to the configured ones, as Better Auth does", () => {
const origins = apiTrustedOrigins({
baseUrl: "https://api.example.test",
trustedOrigins: ["https://dash.example.test"],
});
expect(origins).toEqual(["https://dash.example.test", "https://api.example.test"]);
expect(Object.isFrozen(origins)).toBe(true);
});
it("does not duplicate an API origin already listed (the same-origin topology)", () => {
expect(
apiTrustedOrigins({
baseUrl: "https://ekklesia.example.test",
trustedOrigins: ["https://ekklesia.example.test"],
}),
).toEqual(["https://ekklesia.example.test"]);
});
});
@@ -0,0 +1,131 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const readFileMock = vi.hoisted(() => vi.fn());
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
vi.mock("@ekklesia/shell/dashboard-assets", () => ({
dashboardHostAssetUrls: Object.freeze({
html: "file:///surface/browser/index.html",
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
}),
}));
import {
createDashboardHostAssets,
DASHBOARD_API_ORIGIN_PLACEHOLDER,
DashboardHostAssetError,
loadDashboardHostAssets,
renderDashboardPage,
} from "../src/dashboard-host-assets.js";
const API_ORIGIN = "https://api.example.test";
function sha256Etag(text: string): string {
return `"${createHash("sha256").update(text).digest("base64url")}"`;
}
describe("Dashboard host asset loading", () => {
beforeEach(() => readFileMock.mockReset());
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
const script = "export const dashboardHost = true;\n";
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
const assets = await loadDashboardHostAssets(API_ORIGIN);
expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/index.html"), "utf8"],
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
]);
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
expect(assets).toEqual({
html: rendered,
htmlEtag: sha256Etag(rendered),
script,
scriptEtag: sha256Etag(script),
});
expect(Object.isFrozen(assets)).toBe(true);
});
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
readFileMock.mockRejectedValueOnce(
new Error("ENOENT /home/operator/private/surface-build secret-token"),
);
const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error);
expect(readFileMock).toHaveBeenCalledTimes(1);
expect(failure).toBeInstanceOf(DashboardHostAssetError);
expect(failure).toMatchObject({
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
message: "The Dashboard host browser assets are unavailable.",
name: "DashboardHostAssetError",
});
expect((failure as Error).stack).toBe(
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
);
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
expect(publicText).not.toContain(secret);
}
});
it("fails when the script is missing even though the page was read", async () => {
readFileMock
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
.mockRejectedValueOnce(new Error("ENOENT app.js"));
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
DashboardHostAssetError,
);
expect(readFileMock).toHaveBeenCalledTimes(2);
});
it("rejects an empty page or script as an unavailable shell build", () => {
for (const [html, script] of [
["", "export {};"],
["<html></html>", " \n"],
] as const) {
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
}
});
it("fails startup on a page without exactly one empty API-origin element (ADR 0045)", async () => {
for (const html of [
"<!doctype html>",
`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}${DASHBOARD_API_ORIGIN_PLACEHOLDER}`,
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
]) {
readFileMock.mockReset();
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
DashboardHostAssetError,
);
}
});
});
describe("Dashboard page API origin (ADR 0045)", () => {
const page = `<head>${DASHBOARD_API_ORIGIN_PLACEHOLDER}</head>`;
it("writes the API's origin into the one placeholder, in either topology", () => {
expect(renderDashboardPage(page, "https://api.example.test")).toBe(
'<head><meta content="https://api.example.test" name="ekklesia-api-origin" /></head>',
);
expect(renderDashboardPage(page, "http://127.0.0.1:3000")).toBe(
'<head><meta content="http://127.0.0.1:3000" name="ekklesia-api-origin" /></head>',
);
});
it.each([
"https://api.example.test/",
"https://api.example.test/api",
'https://api.example.test"><script>',
"javascript:alert(1)",
"",
])("refuses a value that is not a canonical http(s) origin: %s", (origin) => {
expect(() => renderDashboardPage(page, origin)).toThrow(DashboardHostAssetError);
});
});
@@ -0,0 +1,347 @@
import { createHash } from "node:crypto";
import {
ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRouteHandler,
type ModuleRuntimeServices,
parsePermissionId,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
createPermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
// Every byte these routes serve must come from the injected startup snapshot. Any filesystem read
// while answering a request — the hallmark of a static directory — fails these tests.
const readFileMock = vi.hoisted(() => vi.fn());
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
import { createApiApp } from "../src/app.js";
import {
createDashboardHostAssets,
type DashboardHostAssets,
} from "../src/dashboard-host-assets.js";
import {
DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
DashboardHostConfigurationError,
} from "../src/dashboard-host-routes.js";
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
const trustedOrigin = "https://dashboard.example.test";
const modulePermission = parsePermissionId("dashboard.page.read");
const PAGE_HTML =
'<!doctype html><html lang="en"><body><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script></body></html>\n';
const SCRIPT = "export const dashboard = 'built shell';\n";
function sha256Etag(text: string): string {
return `"${createHash("sha256").update(text).digest("base64url")}"`;
}
const PAGE_ETAG = sha256Etag(PAGE_HTML);
const SCRIPT_ETAG = sha256Etag(SCRIPT);
function fakeRuntime(): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
};
}
function fakeStore(): AuthorizationStore {
return {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
};
}
function fakeModuleServices(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
function buildApp(
options: {
readonly assets?: DashboardHostAssets;
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
} = {},
) {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
const runtime = fakeRuntime();
const app = createApiApp({
authorization: {
evaluator,
registry: createPermissionRegistry([
{ baseline: "public", description: "Test permission.", id: modulePermission },
]),
store: fakeStore(),
},
moduleRoutes: options.moduleRoutes ?? Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: fakeModuleServices(),
moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
health: readyHealth(),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, evaluator, runtime };
}
function expectPageSecurityHeaders(response: Response): void {
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
}
async function expectSanitizedNotFound(response: Response, label: string): Promise<void> {
expect(response.status, label).toBe(404);
expect(response.headers.get("content-type"), label).toMatch(/^application\/json/u);
expect(await response.json(), label).toEqual({ code: "NOT_FOUND" });
}
describe("GET / (the Dashboard page)", () => {
it("serves the exact startup HTML bytes with revalidation, a strong ETag, and the page security headers", async () => {
const { app } = buildApp();
const response = await app.request(DASHBOARD_HOST_PAGE_PATH);
expect(response.status).toBe(200);
expect(await response.text()).toBe(PAGE_HTML);
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
expectPageSecurityHeaders(response);
// The one CSP directive is the only one: the full policy is a separate decision.
expect(response.headers.get("content-security-policy")).not.toContain(";");
});
it("serves the same page for an emailed-link landing, since the query never selects a file", async () => {
const { app } = buildApp();
const response = await app.request("/?ekklesia_auth=reset-password&token=abc123");
expect(response.status).toBe(200);
expect(await response.text()).toBe(PAGE_HTML);
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
});
it("answers a matching If-None-Match with an empty 304 that keeps the validator and every page security header", async () => {
const { app } = buildApp();
for (const ifNoneMatch of [PAGE_ETAG, `W/${PAGE_ETAG}`, `"other", ${PAGE_ETAG}`, "*"]) {
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
headers: { "If-None-Match": ifNoneMatch },
});
expect(response.status, ifNoneMatch).toBe(304);
expect(await response.text(), ifNoneMatch).toBe("");
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
expect(response.headers.get("cache-control")).toBe("no-cache");
expectPageSecurityHeaders(response);
}
});
it("serves the full page again when the browser holds a different build's validator", async () => {
const { app } = buildApp();
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
headers: { "If-None-Match": sha256Etag("an older build") },
});
expect(response.status).toBe(200);
expect(await response.text()).toBe(PAGE_HTML);
});
it("is unauthenticated and outside the API's CORS policy: it resolves no principal and reflects no origin", async () => {
const { app, evaluator, runtime } = buildApp();
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
});
expect(response.status).toBe(200);
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
expect(response.headers.get("access-control-allow-origin")).toBeNull();
expect(response.headers.get("set-cookie")).toBeNull();
});
});
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
const { app } = buildApp();
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH);
expect(response.status).toBe(200);
expect(await response.text()).toBe(SCRIPT);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("answers a matching If-None-Match with an empty 304 carrying the script's own validator", async () => {
const { app } = buildApp();
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
headers: { "If-None-Match": SCRIPT_ETAG },
});
expect(response.status).toBe(304);
expect(await response.text()).toBe("");
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
expect(response.headers.get("cache-control")).toBe("no-cache");
});
it("does not accept the page's validator as the script's", async () => {
const { app } = buildApp();
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
headers: { "If-None-Match": PAGE_ETAG },
});
expect(response.status).toBe(200);
});
});
describe("Dashboard host boundaries", () => {
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
const { app } = buildApp();
for (const path of [
"/index.html",
"/app.js",
"/app.js.map",
"/assets/ekklesia-dashboard/app.js.map",
"/assets/ekklesia-dashboard/",
"/assets/ekklesia-dashboard",
"/assets/ekklesia-dashboard/app.js/",
"/assets/ekklesia-dashboard/index.html",
"/assets/",
"/assets/ekklesia-dashboard/APP.JS",
"/dashboard",
"/login",
]) {
await expectSanitizedNotFound(await app.request(path), path);
}
});
it("cannot be walked out of: encoded traversal, separators, and double slashes reach no file", async () => {
const { app } = buildApp();
for (const path of [
"/assets/ekklesia-dashboard/%2e%2e/%2e%2e/package.json",
"/assets/ekklesia-dashboard/..%2fapp.js.map",
"/assets/ekklesia-dashboard/%2e%2e%2fapp.js.map",
"/assets/ekklesia-dashboard/app.js%2f..%2f..%2fpackage.json",
"/assets/ekklesia-dashboard/..%5c..%5cpackage.json",
"/%2e%2e/%2e%2e/etc/passwd",
"/../../etc/passwd",
"//assets/ekklesia-dashboard/app.js",
"/assets//ekklesia-dashboard/app.js",
"/assets/ekklesia-dashboard/app.js%00.map",
]) {
const response = await app.request(path);
expect(await response.text(), path).not.toContain(SCRIPT);
expect(response.status, path).toBe(404);
}
expect(readFileMock).not.toHaveBeenCalled();
});
it("never touches the filesystem while answering, even across every served path", async () => {
const { app } = buildApp();
await app.request(DASHBOARD_HOST_PAGE_PATH);
await app.request(DASHBOARD_HOST_SCRIPT_PATH);
await app.request("/assets/ekklesia-dashboard/app.js.map");
expect(readFileMock).not.toHaveBeenCalled();
});
it("serves GET (and Hono's implicit HEAD) only; every other method is the ordinary 404", async () => {
const { app } = buildApp();
const head = await app.request(DASHBOARD_HOST_PAGE_PATH, { method: "HEAD" });
expect(head.status).toBe(200);
expect(head.headers.get("etag")).toBe(PAGE_ETAG);
expect(await head.text()).toBe("");
for (const path of [DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH]) {
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
}
}
});
it("leaves the API, the display host's namespace, and module routes exactly where they were", async () => {
const moduleHandler = vi.fn<ModuleRouteHandler>(
async () => new Response("module", { status: 200 }),
);
const { app } = buildApp({
moduleRoutes: [
Object.freeze({
bodyMode: "buffered",
handler: moduleHandler,
maxBodyBytes: 1024,
method: "GET",
moduleId: "dashboard",
path: "/",
permission: modulePermission,
}) as RegisteredModuleHttpRoute,
],
});
expect(await (await app.request("/api/v1/principal")).json()).toEqual({ kind: "anonymous" });
expect(await (await app.request("/api/v1/health/live")).json()).toEqual({ status: "live" });
expect(await (await app.request("/api/v1/modules/dashboard")).text()).toBe("module");
await expectSanitizedNotFound(await app.request("/api/v1/unknown"), "/api/v1/unknown");
// No display is published here, so the display host's script is absent rather than answered
// by the Dashboard's.
await expectSanitizedNotFound(
await app.request(DISPLAY_HOST_SCRIPT_PATH),
DISPLAY_HOST_SCRIPT_PATH,
);
expect(DASHBOARD_HOST_SCRIPT_PATH.startsWith("/assets/ekklesia-display/")).toBe(false);
expect(await (await app.request(DASHBOARD_HOST_PAGE_PATH)).text()).toBe(PAGE_HTML);
expect(moduleHandler).toHaveBeenCalledTimes(1);
});
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
for (const assets of [
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
{ ...consistent, scriptEtag: PAGE_ETAG },
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
]) {
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
}
});
});
+4
View File
@@ -27,6 +27,8 @@ import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
@@ -139,6 +141,8 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
+373
View File
@@ -0,0 +1,373 @@
import {
ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRouteHandler,
type ModuleRuntimeServices,
parsePermissionId,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
createPermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import {
type ApiHealthOptions,
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test";
const modulePermission = parsePermissionId("health.status.read");
/** What a real driver failure can carry, and what must therefore never reach a response. */
const DRIVER_FAILURE_DETAIL =
"connect ECONNREFUSED postgresql://ekklesia:[email protected]:5432/ekklesia SELECT 1";
function fakeRuntime(): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
};
}
function fakeStore(): AuthorizationStore {
return {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
};
}
function fakeModuleServices(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
function healthOptions(overrides: Partial<ApiHealthOptions> = {}) {
return {
checkDatabase: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
isModuleStartupComplete: vi.fn<() => boolean>().mockReturnValue(true),
...overrides,
};
}
function buildApp(
health: ApiHealthOptions,
moduleRoutes: readonly RegisteredModuleHttpRoute[] = Object.freeze([]),
) {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
const runtime = fakeRuntime();
const app = createApiApp({
authorization: {
evaluator,
registry: createPermissionRegistry([
{ baseline: "public", description: "Test permission.", id: modulePermission },
]),
store: fakeStore(),
},
moduleRoutes,
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: fakeModuleServices(),
moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health,
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, evaluator, runtime };
}
/** Resolves once every already-queued microtask has run, using the real `setImmediate` — the only
* timer functions these tests fake are `setTimeout` and `clearTimeout`. */
function drainMicrotasks(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
/** Whether `promise` settled with no fake time advanced at all: "promptly", made exact. */
async function settlesWithoutWaiting(promise: Response | Promise<Response>): Promise<boolean> {
let settled = false;
void Promise.resolve(promise).then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await drainMicrotasks();
return settled;
}
async function expectNotReady(response: Response): Promise<void> {
expect(response.status).toBe(503);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ code: "NOT_READY" });
}
afterEach(() => {
vi.useRealTimers();
});
describe("GET /api/v1/health/live", () => {
it("returns 200 with a fixed body and Cache-Control: no-store", async () => {
const health = healthOptions();
const { app } = buildApp(health);
const response = await app.request(HEALTH_LIVE_PATH);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("content-type")).toMatch(/^application\/json/u);
expect(await response.json()).toEqual({ status: "live" });
});
it("does not consult the database or module startup, so it stays 200 while readiness is 503", async () => {
const health = healthOptions({
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
isModuleStartupComplete: vi.fn().mockReturnValue(false),
});
const { app } = buildApp(health);
const response = await app.request(HEALTH_LIVE_PATH);
expect(response.status).toBe(200);
expect(health.checkDatabase).not.toHaveBeenCalled();
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
});
});
describe("GET /api/v1/health/ready", () => {
it("returns 200 with a fixed body when the database answers and module startup completed", async () => {
const health = healthOptions();
const { app } = buildApp(health);
const response = await app.request(HEALTH_READY_PATH);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ status: "ready" });
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
});
it("returns a fixed 503 when the database query fails, carrying nothing from the failure", async () => {
const health = healthOptions({
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
});
const { app } = buildApp(health);
const response = await app.request(HEALTH_READY_PATH);
const text = await response.clone().text();
await expectNotReady(response);
for (const fragment of ["ECONNREFUSED", "hunter2", "db.internal", "SELECT", "Error"]) {
expect(text).not.toContain(fragment);
}
});
it("treats a check that throws synchronously exactly like one that rejects", async () => {
const health = healthOptions({
checkDatabase: vi.fn(() => {
throw new Error(DRIVER_FAILURE_DETAIL);
}),
});
const { app } = buildApp(health);
await expectNotReady(await app.request(HEALTH_READY_PATH));
});
it("returns 503 without touching the database before module startup completed", async () => {
const health = healthOptions({ isModuleStartupComplete: vi.fn().mockReturnValue(false) });
const { app } = buildApp(health);
await expectNotReady(await app.request(HEALTH_READY_PATH));
expect(health.checkDatabase).not.toHaveBeenCalled();
});
it("returns 503 when shutdown began while the database query was in flight", async () => {
let startupComplete = true;
const health = healthOptions({
checkDatabase: vi.fn(async () => {
startupComplete = false;
}),
isModuleStartupComplete: () => startupComplete,
});
const { app } = buildApp(health);
await expectNotReady(await app.request(HEALTH_READY_PATH));
});
it("starts a fresh check for each sequential probe rather than caching a success", async () => {
const health = healthOptions();
const { app } = buildApp(health);
await app.request(HEALTH_READY_PATH);
await app.request(HEALTH_READY_PATH);
expect(health.checkDatabase).toHaveBeenCalledTimes(2);
});
it("reports ready for a slow query that still answers inside the bound", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
let answer: () => void = () => undefined;
const health = healthOptions({
checkDatabase: vi.fn(
() =>
new Promise<void>((resolve) => {
answer = resolve;
}),
),
});
const { app } = buildApp(health);
const pending = app.request(HEALTH_READY_PATH);
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS - 1);
answer();
expect((await pending).status).toBe(200);
});
});
describe("readiness never piles up database checks", () => {
it("keeps exactly one check in flight for a query that never settles, while every probe answers 503 promptly", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const health = healthOptions({
checkDatabase: vi.fn(() => new Promise<void>(() => undefined)),
});
const { app } = buildApp(health);
// Probes arriving inside the first check's window share it and its single deadline.
const first = app.request(HEALTH_READY_PATH);
const concurrent = app.request(HEALTH_READY_PATH);
await drainMicrotasks();
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
await expectNotReady(await first);
await expectNotReady(await concurrent);
// After the deadline the query is still unsettled: no new check starts, and each probe answers
// without waiting out another bound.
for (let probe = 0; probe < 20; probe += 1) {
const pending = app.request(HEALTH_READY_PATH);
expect(await settlesWithoutWaiting(pending)).toBe(true);
await expectNotReady(await pending);
}
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
});
it("starts a new check only once the timed-out query has actually settled", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
let settleHungQuery: (error: Error) => void = () => undefined;
const checkDatabase = vi
.fn<() => Promise<void>>()
.mockImplementationOnce(
() =>
new Promise<void>((_resolve, reject) => {
settleHungQuery = reject;
}),
)
.mockResolvedValue(undefined);
const { app } = buildApp(healthOptions({ checkDatabase }));
const first = app.request(HEALTH_READY_PATH);
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
await expectNotReady(await first);
await expectNotReady(await app.request(HEALTH_READY_PATH));
expect(checkDatabase).toHaveBeenCalledTimes(1);
// The database cancels the abandoned statement; the next probe may finally ask again.
settleHungQuery(new Error(DRIVER_FAILURE_DETAIL));
await drainMicrotasks();
const recovered = await app.request(HEALTH_READY_PATH);
expect(recovered.status).toBe(200);
expect(checkDatabase).toHaveBeenCalledTimes(2);
});
});
describe("health route boundaries", () => {
it.each([
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
])("%s accepts GET only, answering every other method with a fixed 405 and never checking readiness", async (path) => {
const health = healthOptions();
const { app } = buildApp(health);
for (const method of ["HEAD", "POST", "PUT", "PATCH", "DELETE"]) {
const response = await app.request(path, { method });
expect(response.status, method).toBe(405);
expect(response.headers.get("allow")).toBe("GET");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.text()).toBe("");
}
expect(health.checkDatabase).not.toHaveBeenCalled();
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
});
it.each([
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
])("%s is unauthenticated: it never resolves a principal or evaluates authorization, even with a cookie", async (path) => {
const { app, evaluator, runtime } = buildApp(healthOptions());
const response = await app.request(path, {
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
});
expect(response.status).toBe(200);
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
});
it('cannot be shadowed by a module, even one whose id is "health"', async () => {
const moduleHandler = vi.fn<ModuleRouteHandler>(
async () => new Response("module", { status: 200 }),
);
const moduleRoutes = ["/live", "/ready"].map(
(path) =>
Object.freeze({
bodyMode: "buffered",
handler: moduleHandler,
maxBodyBytes: 1024,
method: "GET",
moduleId: "health",
path,
permission: modulePermission,
}) as RegisteredModuleHttpRoute,
);
const { app } = buildApp(healthOptions(), moduleRoutes);
expect(await (await app.request(HEALTH_LIVE_PATH)).json()).toEqual({ status: "live" });
expect(await (await app.request(HEALTH_READY_PATH)).json()).toEqual({ status: "ready" });
expect(moduleHandler).not.toHaveBeenCalled();
// The module's own routes still live where every module route lives, and only there.
const moduleResponse = await app.request("/api/v1/modules/health/ready");
expect(await moduleResponse.text()).toBe("module");
expect(moduleHandler).toHaveBeenCalledTimes(1);
});
});
+70
View File
@@ -9,11 +9,21 @@ const drizzleAdapterMock = vi.hoisted(() => vi.fn());
const serveMock = vi.hoisted(() => vi.fn());
const processOnSpy = vi.hoisted(() => vi.fn());
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
// Every startup reads the built Dashboard (ADR 0044), and this file never depends on a built shell,
// so the loader answers a small consistent snapshot unless a test makes it fail.
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
loadDashboardHostAssetsMock.mockImplementation(async () =>
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
);
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
});
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
@@ -35,7 +45,13 @@ import {
import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiApp } from "../src/app.js";
import { createApiRuntimeConfig } from "../src/config.js";
import { DashboardHostAssetError } from "../src/dashboard-host-assets.js";
import { DisplayHostAssetError } from "../src/display-host-assets.js";
import {
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.js";
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
function validConfig() {
@@ -447,6 +463,36 @@ describe("Ekklesia API shutdown lifecycle", () => {
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("answers readiness with one bounded ping on the shared pool, and turns it 503 the moment shutdown begins", async () => {
const query = emptyQuery();
poolConstructor.mockImplementation(function fakePool() {
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig());
query.mockClear();
const ready = await running.app.request(HEALTH_READY_PATH);
expect(ready.status).toBe(200);
expect(query).toHaveBeenCalledTimes(1);
// The statement timeout is server-side and scoped to this one transaction, so PostgreSQL itself
// cancels a stalled ping rather than the route merely abandoning it.
expect(query).toHaveBeenCalledWith(
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${READINESS_DATABASE_TIMEOUT_MS}; SELECT 1; COMMIT`,
);
const shutdown = running.shutdown();
const draining = await running.app.request(HEALTH_READY_PATH);
expect(draining.status).toBe(503);
expect(await draining.json()).toEqual({ code: "NOT_READY" });
// Draining is not dead: liveness still answers while in-flight requests finish.
expect((await running.app.request(HEALTH_LIVE_PATH)).status).toBe(200);
expect(query).toHaveBeenCalledTimes(1);
await shutdown;
});
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
@@ -1022,6 +1068,30 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
expect(serveMock).not.toHaveBeenCalled();
});
it("fails on missing Dashboard assets before importing module code and still closes startup resources", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
const { poolEnd } = mockPoolAndAuth();
loadDashboardHostAssetsMock.mockRejectedValueOnce(new DashboardHostAssetError());
const failure = await startApi(validConfig(), {
modulesDirectoryOverride: modulesDirectory,
}).catch((error: unknown) => error);
expect(failure).toMatchObject({
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
message: "The Dashboard host browser assets are unavailable.",
name: "DashboardHostAssetError",
});
expect((failure as Error).stack).toBe(
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
);
expect(recorder.events).toEqual([]);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("does not read display-host assets when the eligible set declares no display", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
@@ -35,6 +35,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import {
createFakeModuleSecretSource,
createInMemoryModuleConfigurationStore,
@@ -189,6 +191,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
store: authorizationStore,
},
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleConfiguration: {
bootSettings,
discoveredModules: modules,
@@ -38,6 +38,8 @@ import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import {
createInMemoryModuleLifecycleStore,
@@ -198,6 +200,8 @@ async function install(options: InstallOptions): Promise<Installation> {
store: fakeAuthorizationStore(),
},
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
? { displayHostAssets: options.assets ?? displayHostAssets }
: {}),
@@ -23,6 +23,8 @@ import {
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import {
canonicalizeVaryHeader,
@@ -123,6 +125,8 @@ function buildModuleApp(options: {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime,
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
});
@@ -33,6 +33,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
@@ -171,6 +173,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
store: authorizationStore,
},
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
moduleLifecycle: {
@@ -36,6 +36,8 @@ import {
} from "@ekklesia/shell";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js";
import {
@@ -288,6 +290,8 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime,
trustedOrigins: ["https://dashboard.example.test"],
});
@@ -25,6 +25,8 @@ import {
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
@@ -208,6 +210,8 @@ async function install(options: InstallOptions): Promise<Installation> {
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime,
trustedOrigins: [trustedOrigin],
});
+4
View File
@@ -21,6 +21,8 @@ import { Window } from "happy-dom";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js";
import {
@@ -255,6 +257,8 @@ async function install(
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime,
trustedOrigins: [trustedOrigin],
});
@@ -0,0 +1,13 @@
import {
createDashboardHostAssets,
type DashboardHostAssets,
} from "../../src/dashboard-host-assets.js";
/** A small, internally consistent Dashboard snapshot for compositions whose tests do not exercise
* the Dashboard host. Its own behavior is tested in `../dashboard-host-routes.test.ts`. */
export function testDashboardHostAssets(): DashboardHostAssets {
return createDashboardHostAssets(
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
"export const dashboardHost = true;\n",
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { ApiHealthOptions } from "../../src/health-routes.js";
/** Readiness inputs for a composition whose tests do not exercise health: the database answers and
* module startup has completed. Health behavior itself is tested in `../health-routes.test.ts`. */
export function readyHealth(): ApiHealthOptions {
return {
checkDatabase: async () => undefined,
isModuleStartupComplete: () => true,
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/core",
"version": "0.1.0",
"version": "0.1.2",
"private": true,
"type": "module",
"files": [
+68 -4
View File
@@ -757,6 +757,35 @@ function identitySchemaSummary(): string {
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
}
/**
* A heuristic for "same site" between two canonical origins, for a warning only (ADR 0045).
*
* The same hostname on another port is the same site. Otherwise the two hostnames must be siblings:
* identical after their first label, with that parent itself holding at least one dot
* (`dash.example.org` and `api.example.org`). Without the Public Suffix List this can be wrong in
* both directions for unusual names, which is why its result is a warning and never a failure.
*/
function plausiblySameSite(dashboardOrigin: string, apiOrigin: string): boolean {
const dashboard = new URL(dashboardOrigin).hostname;
const api = new URL(apiOrigin).hostname;
if (dashboard === api) {
return true;
}
// An IP address is a site of its own; two different ones never share one.
const isIpAddress = (hostname: string): boolean =>
hostname.startsWith("[") || /^[0-9]+(\.[0-9]+){3}$/u.test(hostname);
if (isIpAddress(dashboard) || isIpAddress(api)) {
return false;
}
const parent = (hostname: string): string => hostname.slice(hostname.indexOf(".") + 1);
return (
dashboard.includes(".") &&
api.includes(".") &&
parent(dashboard) === parent(api) &&
parent(dashboard).includes(".")
);
}
/**
* Pure configuration validation only: never constructs Better Auth, never opens a database
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
@@ -769,6 +798,7 @@ function identityConfigurationCheck(
const hasAnyValue =
input?.secret !== undefined ||
input?.baseUrl !== undefined ||
(input?.dashboardOrigin ?? "") !== "" ||
input?.publicSignupEnabled !== undefined ||
(input?.trustedOrigins?.length ?? 0) > 0;
@@ -783,17 +813,43 @@ function identityConfigurationCheck(
const config = createIdentityConfig(
{
baseUrl: input?.baseUrl,
// Validated exactly as the API validates it at startup, so a Dashboard origin the API would
// refuse (untrusted, insecure, malformed) fails here first.
dashboardOrigin: input?.dashboardOrigin,
publicSignupEnabled: input?.publicSignupEnabled,
secret: input?.secret,
trustedOrigins: input?.trustedOrigins,
},
{ environment: input?.environment ?? "production" },
);
const signupDetail = config.publicSignupEnabled
? "Public signup is explicitly ENABLED."
: "Public signup is disabled (safe default).";
if (config.dashboardOrigin === config.baseUrl) {
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
schemaDetail,
signupDetail,
"Topology: same-origin (the Dashboard is served from the API's origin).",
]);
}
const topologyDetail = "Topology: split (the Dashboard and the API are on separate origins).";
if (!plausiblySameSite(config.dashboardOrigin, config.baseUrl)) {
return freezeCheck(
"identity.configuration",
"warning",
"Identity configuration valid; split origins may not share a site",
[
schemaDetail,
signupDetail,
topologyDetail,
"The Dashboard and API hostnames are not siblings under one parent domain. Browsers send the session cookie across split origins only within one registrable domain (ADR 0045); Doctor cannot check the Public Suffix List, so confirm it.",
],
);
}
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
schemaDetail,
config.publicSignupEnabled
? "Public signup is explicitly ENABLED."
: "Public signup is disabled (safe default).",
signupDetail,
topologyDetail,
]);
} catch (error) {
const code =
@@ -849,17 +905,25 @@ function databaseTransportCheck(
}
}
/** Doctor reads only its own process's environment, so it says so: run as the reference
* deployment's `migrate`, it is given the API's values by the proxy overlay, but run any other way
* it may be checking a configuration the API is not running. */
const CLIENT_ADDRESS_SCOPE =
"Checked: the environment Doctor runs with. In the reference deployment `migrate` is given the API's client-address values; a running API's own value is shown by `docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.";
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
try {
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
if (config.mode === "direct") {
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
CLIENT_ADDRESS_SCOPE,
]);
}
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
CLIENT_ADDRESS_SCOPE,
]);
} catch (error) {
const code =
@@ -870,7 +934,7 @@ function clientAddressCheck(input: ClientAddressConfigInput | undefined): Doctor
"identity.client-address",
"failure",
"Client-address configuration invalid",
[`[${code}] Configured client-address values failed validation.`],
[`[${code}] Configured client-address values failed validation.`, CLIENT_ADDRESS_SCOPE],
);
}
}
+1
View File
@@ -117,6 +117,7 @@ Identity
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
✓ Direct client-address mode
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
Checked: the environment Doctor runs with. In the reference deployment \`migrate\` is given the API's client-address values; a running API's own value is shown by \`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE\`.
Object storage
○ Object storage not configured
+97
View File
@@ -705,6 +705,88 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture
});
describe("Dashboard and API topology (ADR 0045)", () => {
const secret = "correct-horse-battery-staple-0123456789";
async function identityCheck(identity: {
readonly baseUrl: string;
readonly dashboardOrigin?: string;
readonly environment?: "development" | "production";
readonly trustedOrigins: readonly string[];
}) {
const report = await diagnoseEkklesia(
{
...options(),
identityConfiguration: { environment: "production", secret, ...identity },
},
dependencies(),
);
return { check: check(report, "identity.configuration"), report };
}
it("reports the same-origin topology when no Dashboard origin is set", async () => {
const { check: identity } = await identityCheck({
baseUrl: "https://ekklesia.example.test",
trustedOrigins: ["https://ekklesia.example.test"],
});
expect(identity.status).toBe("pass");
expect(identity.details).toContain(
"Topology: same-origin (the Dashboard is served from the API's origin).",
);
});
it("reports the split topology for sibling hostnames without printing either", async () => {
const { check: identity, report } = await identityCheck({
baseUrl: "https://api.example.test",
dashboardOrigin: "https://dash.example.test",
trustedOrigins: ["https://dash.example.test"],
});
expect(identity.status).toBe("pass");
expect(identity.details).toContain(
"Topology: split (the Dashboard and the API are on separate origins).",
);
expect(JSON.stringify(report)).not.toContain("dash.example.test");
expect(JSON.stringify(report)).not.toContain("api.example.test");
});
it.each([
["different registrable domains", "https://dash.example.test", "https://api.example.org"],
["two IP addresses", "https://10.0.0.2", "https://10.0.0.1"],
["a parent that is only a top-level domain", "https://dash.test", "https://api.test"],
])("warns, without failing, when split hostnames may not share a site: %s", async (_case, dashboardOrigin, baseUrl) => {
const { check: identity, report } = await identityCheck({
baseUrl,
dashboardOrigin,
trustedOrigins: [dashboardOrigin],
});
expect(identity.status).toBe("warning");
expect(identity.details.join("\n")).toContain("not siblings under one parent domain");
expect(doctorExitCode(report)).toBe(0);
});
it("treats another port on the same host as the same site (split development)", async () => {
const { check: identity } = await identityCheck({
baseUrl: "http://localhost:3000",
dashboardOrigin: "http://localhost:5173",
environment: "development",
trustedOrigins: ["http://localhost:5173"],
});
expect(identity.status).toBe("pass");
});
it("fails, as the API's startup would, for a Dashboard origin the API does not trust", async () => {
const { check: identity, report } = await identityCheck({
baseUrl: "https://api.example.test",
dashboardOrigin: "https://dash.example.test",
trustedOrigins: ["https://other.example.test"],
});
expect(identity.status).toBe("failure");
expect(identity.details.some((detail) => detail.includes("DASHBOARD_ORIGIN_UNTRUSTED"))).toBe(
true,
);
expect(doctorExitCode(report)).toBe(1);
});
});
it("fails identity configuration for an invalid secret without leaking it", async () => {
const hostileSecret = "s3cr3t-do-not-log";
const report = await diagnoseEkklesia(
@@ -876,6 +958,21 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(report.result).toBe("ready-with-warnings");
});
it("says whose client-address configuration it checked: this process's, not a running API's", async () => {
for (const clientAddress of [
undefined,
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["10.0.0.1"] },
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["bad"] },
]) {
const report = await diagnoseEkklesia(
{ ...options(), ...(clientAddress ? { clientAddress } : {}) },
dependencies(),
);
const details = check(report, "identity.client-address").details;
expect(details.some((detail) => detail.includes("environment Doctor runs with"))).toBe(true);
}
});
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
const report = await diagnoseEkklesia(
{
+126
View File
@@ -0,0 +1,126 @@
# Ekklesia reference proxy configuration (ADR 0044). Copy to deploy/Caddyfile, which the proxy
# overlay mounts read-only. Hostnames, the ACME contact, and the tunnel connector's address come from
# the proxy service's environment (deploy/.env), so this file normally needs no edits.
{
# ACME account contact. Certificates use the HTTP-01 challenge on port 80 by default, which needs
# the Ekklesia and storage hostnames to resolve publicly to this host with 80 and 443 reachable.
email {$EKKLESIA_ACME_EMAIL}
# DNS-01, for an installation that keeps Ekklesia off the public Internet, serves only a LAN
# (split-horizon DNS), or is reachable only through Cloudflare Tunnel. Cloudflare terminates TLS at
# its edge, so HTTP-01 cannot reach this proxy through a tunnel.
#
# DNS-01 needs a DNS provider module that the stock Caddy image does not contain. Build an image
# with it (for example `xcaddy build --with github.com/caddy-dns/cloudflare`), pin that image by
# digest in your own overlay, give it CLOUDFLARE_API_TOKEN through a root-owned env file, then
# enable:
#
# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
servers {
# A client-address header is honored only from the tunnel connector's fixed address
# (compose.tunnel.yaml). From every other peer, the client address is the TCP peer itself and
# any such header is ignored. Without a tunnel, nothing has this address.
trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32
client_ip_headers CF-Connecting-IP
trusted_proxies_strict
}
# Access logs are off unless enabled. If you enable them, keep request headers out: they carry
# cookies and forwarding chains (docs/development/security.md, launch checklist item 7).
}
# What every request to the API carries upstream: exactly one forwarding header, overwritten with
# the client address Caddy resolved, and none of the headers a caller could use to choose its own.
(ekklesia_forwarding) {
header_up X-Forwarded-For {client_ip}
header_up -CF-Connecting-IP
header_up -X-Real-IP
header_up -Forwarded
header_up -True-Client-IP
}
{$EKKLESIA_HOSTNAME} {
# Everything the API serves: the Dashboard at /, the API, display pages and their service
# worker, and the Dashboard and display host scripts. Any other path is refused here.
@ekklesia path / /api/* /display/* /assets/*
# Module realtime is Server-Sent Events: flush every event immediately.
@realtime path /api/v1/module-realtime/*
handle @realtime {
reverse_proxy api:3000 {
import ekklesia_forwarding
flush_interval -1
}
}
handle @ekklesia {
# Request bodies stream through unbuffered and unlimited here; the API enforces the platform's
# ceilings itself (8 KiB for authentication, up to 32 MiB for a streamed module route).
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle {
respond 404
}
}
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
{$EKKLESIA_STORAGE_HOSTNAME} {
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
# with fetch() get CORS: the Ekklesia origin, which serves the Dashboard and the displays. An <img> needs none. Every object
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
@cors_origin header Origin https://{$EKKLESIA_HOSTNAME}
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
# no request header beyond the CORS-safelisted ones.
@preflight method OPTIONS
handle @preflight {
header @cors_origin {
Access-Control-Allow-Origin {http.request.header.Origin}
Access-Control-Allow-Methods "GET, HEAD"
Access-Control-Max-Age 600
}
header Vary Origin
respond 204
}
handle {
# Set when the response is written, after the upstream's own CORS headers were removed below.
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
header >Vary Origin
reverse_proxy https://object-storage:8443 {
# An S3 signature covers the Host header: pass the original through unchanged, or every
# signed URL fails verification.
header_up Host {host}
header_up -CF-Connecting-IP
header_up -X-Real-IP
header_up -Forwarded
header_up -True-Client-IP
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
# is not the deployment's policy.
header_down -Access-Control-Allow-Origin
header_down -Access-Control-Allow-Methods
header_down -Access-Control-Allow-Headers
header_down -Access-Control-Expose-Headers
header_down -Access-Control-Allow-Credentials
header_down -Access-Control-Max-Age
header_down -Vary
transport http {
# object-storage presents a certificate from the installation-private CA.
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
tls_server_name object-storage
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
versions 1.1
}
}
}
}
+152
View File
@@ -0,0 +1,152 @@
# Ekklesia reference proxy configuration for the split topology (ADR 0045): the Dashboard and the API
# on separate hostnames of one site, for example dash.example.org and api.example.org. For the
# default same-origin topology use Caddyfile.example instead. Copy this file to deploy/Caddyfile.
#
# Hostnames, the ACME contact, and the tunnel connector's address come from the proxy service's
# environment (deploy/.env): EKKLESIA_DASHBOARD_HOSTNAME is the Dashboard's, EKKLESIA_HOSTNAME the
# API's (which also serves the display host), EKKLESIA_STORAGE_HOSTNAME the storage origin's. Only
# the two site blocks below differ from Caddyfile.example.
{
# ACME account contact. Certificates use the HTTP-01 challenge on port 80 by default, which needs
# all three hostnames to resolve publicly to this host with 80 and 443 reachable.
email {$EKKLESIA_ACME_EMAIL}
# DNS-01, for an installation that keeps Ekklesia off the public Internet, serves only a LAN
# (split-horizon DNS), or is reachable only through Cloudflare Tunnel. Cloudflare terminates TLS at
# its edge, so HTTP-01 cannot reach this proxy through a tunnel.
#
# DNS-01 needs a DNS provider module that the stock Caddy image does not contain. Build an image
# with it (for example `xcaddy build --with github.com/caddy-dns/cloudflare`), pin that image by
# digest in your own overlay, give it CLOUDFLARE_API_TOKEN through a root-owned env file, then
# enable:
#
# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
servers {
# A client-address header is honored only from the tunnel connector's fixed address
# (compose.tunnel.yaml). From every other peer, the client address is the TCP peer itself and
# any such header is ignored. Without a tunnel, nothing has this address.
trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32
client_ip_headers CF-Connecting-IP
trusted_proxies_strict
}
# Access logs are off unless enabled. If you enable them, keep request headers out: they carry
# cookies and forwarding chains (docs/development/security.md, launch checklist item 7).
}
# What every request to the API carries upstream: exactly one forwarding header, overwritten with
# the client address Caddy resolved, and none of the headers a caller could use to choose its own.
(ekklesia_forwarding) {
header_up X-Forwarded-For {client_ip}
header_up -CF-Connecting-IP
header_up -X-Real-IP
header_up -Forwarded
header_up -True-Client-IP
}
# The Dashboard: its page and its one script, nothing else. Emailed verification and reset links land
# on `/` with a query. `/api/*` is refused here on purpose, so the session cookie has exactly one
# home, the API hostname, and the Dashboard can never fall back to a same-origin API.
{$EKKLESIA_DASHBOARD_HOSTNAME} {
@dashboard path / /assets/ekklesia-dashboard/*
handle @dashboard {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle {
respond 404
}
}
# The API, and the display host on the API's own origin: a screen's pages, service worker, device
# cookies, and requests are all same-origin (ADR 0045). Nothing is served at `/`.
{$EKKLESIA_HOSTNAME} {
@api path /api/* /display/* /assets/ekklesia-display/*
# Module realtime is Server-Sent Events: flush every event immediately.
@realtime path /api/v1/module-realtime/*
handle @realtime {
reverse_proxy api:3000 {
import ekklesia_forwarding
flush_interval -1
}
}
handle @api {
# Request bodies stream through unbuffered and unlimited here; the API enforces the platform's
# ceilings itself (8 KiB for authentication, up to 32 MiB for a streamed module route).
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle {
respond 404
}
}
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
{$EKKLESIA_STORAGE_HOSTNAME} {
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
# with fetch() get CORS: the API origin, where displays run, and the Dashboard origin,
# where a module's preview may cache images too. An <img> needs none. Every object
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
@cors_origin {
header Origin https://{$EKKLESIA_HOSTNAME}
header Origin https://{$EKKLESIA_DASHBOARD_HOSTNAME}
}
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
# no request header beyond the CORS-safelisted ones.
@preflight method OPTIONS
handle @preflight {
header @cors_origin {
Access-Control-Allow-Origin {http.request.header.Origin}
Access-Control-Allow-Methods "GET, HEAD"
Access-Control-Max-Age 600
}
header Vary Origin
respond 204
}
handle {
# Set when the response is written, after the upstream's own CORS headers were removed below.
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
header >Vary Origin
reverse_proxy https://object-storage:8443 {
# An S3 signature covers the Host header: pass the original through unchanged, or every
# signed URL fails verification.
header_up Host {host}
header_up -CF-Connecting-IP
header_up -X-Real-IP
header_up -Forwarded
header_up -True-Client-IP
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
# is not the deployment's policy.
header_down -Access-Control-Allow-Origin
header_down -Access-Control-Allow-Methods
header_down -Access-Control-Allow-Headers
header_down -Access-Control-Expose-Headers
header_down -Access-Control-Allow-Credentials
header_down -Access-Control-Max-Age
header_down -Vary
transport http {
# object-storage presents a certificate from the installation-private CA.
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
tls_server_name object-storage
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
versions 1.1
}
}
}
}
+131
View File
@@ -0,0 +1,131 @@
# Ekklesia's one production image (ADR 0044): the `api` service and every operator-run CLI, from one
# build. See deploy/README.md for how to build and run it.
#
# The build context is an already-assembled workspace: the platform, zero or more module packages in
# `modules/<id>`, and the `pnpm-lock.yaml` that describes exactly that set. This file clones nothing
# and chooses no modules. With an empty or absent `modules/`, it builds the platform alone.
#
# No `# syntax=` directive, deliberately: it would make BuildKit pull an unpinned frontend image.
# The built-in frontend supports everything below, including cache mounts.
# Node.js 24.18.0 (matching `.node-version`) on Debian 13 slim, pinned by its multi-architecture
# index digest. glibc rather than musl, because module dependencies such as sharp ship glibc
# prebuilt binaries. Every stage starts from this exact image.
FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573 AS base
# ---------------------------------------------------------------------------------------------------
# toolchain: pnpm through Corepack, at exactly the root `packageManager` version.
# ---------------------------------------------------------------------------------------------------
FROM base AS toolchain
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
PNPM_HOME=/pnpm \
TURBO_TELEMETRY_DISABLED=1 \
DO_NOT_TRACK=1
WORKDIR /opt/ekklesia
COPY package.json ./
# Corepack reads `packageManager` (pnpm@<exact version>) from the root manifest, so the version is
# declared once, in the repository, and never restated here.
RUN corepack enable && corepack install
# ---------------------------------------------------------------------------------------------------
# manifests: the lockfile, workspace configuration, and every workspace package.json, at their
# repository paths. Dependency installation keys on these alone, so a source-only change reuses the
# installed layers.
# ---------------------------------------------------------------------------------------------------
FROM base AS manifests
WORKDIR /context
COPY . .
RUN set -eu; \
mkdir /manifests; \
cp package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /manifests/; \
for manifest in apps/*/package.json builders/*/package.json core/package.json \
modules/*/package.json packages/*/package.json surfaces/*/package.json \
templates/*/package.json; do \
if [ -f "$manifest" ]; then install -D -m 0644 "$manifest" "/manifests/$manifest"; fi; \
done
# ---------------------------------------------------------------------------------------------------
# build: full install, validation, build, then the exact runtime file set.
# ---------------------------------------------------------------------------------------------------
FROM toolchain AS build
# Sources first, then one install: workspace packages publish `bin` commands from source files (the
# module UI builder's, which every module build invokes), and pnpm links a bin only when its target
# already exists. The store cache mount keeps a rebuild off the network all the same.
COPY . .
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
pnpm install --frozen-lockfile --store-dir /pnpm/store
# The same gates `pnpm verify` runs for boundaries, modules, and migrations, against exactly the
# module set in this context. `pnpm boundaries` comes first and reads only sources: among its rules,
# a module that imports something at run time without declaring it a production dependency fails
# here, because the production-only install below would leave it unresolvable at API startup. Each
# validate script builds first; the final build is a Turbo cache hit that keeps the intent explicit.
RUN pnpm boundaries && pnpm modules:validate && pnpm migrations:validate && pnpm build
# What the runtime resolves at startup, at the paths it resolves them from:
# - every workspace package's `dist/` (every package exports only from `dist/`), which includes
# the Dashboard and display host assets under `surfaces/shell/dist/browser/`;
# - Core's migrations, `core/drizzle/` with its `meta/` journal;
# - each module directory whole, minus `node_modules`, because each manifest names its own
# entrypoints, UI artifacts, and migrations directory.
# `node_modules` comes from the production-only install below, never from this stage.
RUN set -eu; \
mkdir -p /runtime/core /runtime/modules; \
for package in apps/* core packages/* surfaces/*; do \
if [ -d "$package/dist" ]; then \
mkdir -p "/runtime/$package"; \
cp -a "$package/dist" "/runtime/$package/dist"; \
fi; \
done; \
cp -a core/drizzle /runtime/core/drizzle; \
if [ -d modules ]; then \
tar -C modules --exclude='node_modules' -cf - . | tar -C /runtime/modules -xf -; \
fi
# ---------------------------------------------------------------------------------------------------
# prod-deps: the same lockfile, production dependencies only. No compiler, bundler, or test runner
# reaches the runtime image.
# ---------------------------------------------------------------------------------------------------
FROM toolchain AS prod-deps
COPY --from=manifests /manifests/ ./
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
pnpm install --frozen-lockfile --prod --store-dir /pnpm/store
# ---------------------------------------------------------------------------------------------------
# runtime
# ---------------------------------------------------------------------------------------------------
FROM base AS runtime
ARG VERSION=""
ARG REVISION=""
ARG SOURCE=""
LABEL org.opencontainers.image.title="Ekklesia" \
org.opencontainers.image.description="Ekklesia API, Dashboard, display host, and operator CLIs" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.source="${SOURCE}" \
org.opencontainers.image.base.name="docker.io/library/node:24.18.0-trixie-slim" \
org.opencontainers.image.base.digest="sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573"
# Only the container's own listen address differs from the platform default: outside a container
# the API still binds 127.0.0.1. `production` is stated rather than relied on as the default.
ENV NODE_ENV=production \
EKKLESIA_API_HOST=0.0.0.0 \
EKKLESIA_API_PORT=3000
# The repository layout, preserved: the API finds the repository root three directories above
# `apps/api/dist/`, and every CLI uses its working directory as the root.
WORKDIR /opt/ekklesia
COPY --from=prod-deps /opt/ekklesia/ ./
COPY --from=build /runtime/ ./
# Files stay root-owned: the unprivileged runtime user can read the installation, never modify it.
USER node
EXPOSE 3000
# Readiness, not liveness: an instance whose database is unreachable or that is shutting down is
# unhealthy. Node's own fetch, so the image needs no curl.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.EKKLESIA_API_PORT || '3000') + '/api/v1/health/ready', { signal: AbortSignal.timeout(4000) }).then((response) => process.exit(response.status === 200 ? 0 : 1), () => process.exit(1))"]
# `pnpm api:start` without its build step and without `--env-file`: configuration comes only from
# the container environment. The same image runs every CLI: `node core/dist/cli/<command>.js`.
CMD ["node", "apps/api/dist/server.js"]
+503
View File
@@ -0,0 +1,503 @@
# Deployment
This directory holds Ekklesia's reference self-hosted deployment
([ADR 0044](../docs/architecture/decisions/0044-self-hosted-reference-deployment.md)): one Linux
host, one Docker Compose project, and the production container image it runs. Nothing here is
specific to one organization.
| File | What it is |
| --- | --- |
| [`compose.yaml`](compose.yaml) | The base: `api`, `postgres`, `object-storage`, and the operator-only `migrate`. Publishes no port. |
| [`compose.proxy.yaml`](compose.proxy.yaml) | Overlay: Caddy as the TLS proxy, the only service with published ports. |
| [`compose.tunnel.yaml`](compose.tunnel.yaml) | Overlay on top of the proxy: a Cloudflare Tunnel connector. |
| [`Caddyfile.example`](Caddyfile.example) | The proxy's configuration for the default same-origin topology; copy to `Caddyfile`. |
| [`Caddyfile.split.example`](Caddyfile.split.example) | The proxy's configuration when the Dashboard and the API have separate hostnames ([Split Dashboard and API origins](#split-dashboard-and-api-origins)); copy to `Caddyfile` instead. |
| [`postgres/pg_hba.conf`](postgres/pg_hba.conf) | PostgreSQL client authentication: TLS only, SCRAM passwords. |
| [`env/`](env/) | Commented examples of every env file. No values. |
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
## The reference deployment
### Shape
```sh
cd deploy
# Base and proxy: the reference.
docker compose -f compose.yaml -f compose.proxy.yaml up -d
# Base, proxy, and Cloudflare Tunnel.
docker compose -f compose.yaml -f compose.proxy.yaml -f compose.tunnel.yaml up -d
# Base only, behind a reverse proxy the installation already runs.
docker compose -f compose.yaml -f compose.myproxy.yaml up -d
```
Pass the same `-f` files to every later `up`, `down`, `ps`, and `logs`. `migrate` commands need only
the base: `docker compose run --rm migrate …` from this directory uses `compose.yaml` by default.
| Network | Kind | Members |
| --- | --- | --- |
| `data` | internal | `postgres`, `api`, `migrate` |
| `storage` | internal | `object-storage`, `api`, `migrate` |
| `egress` | bridge | `api`, `migrate` (outbound only, for example an SMTP relay) |
| `edge` | bridge, fixed subnet (proxy overlay) | `proxy` (fixed address), `api`, `object-storage`, `tunnel` (fixed address) |
PostgreSQL is only ever on `data`, so no proxy and no tunnel can reach it. `internal` networks have
no route off the host. Object storage joins `edge` only when the proxy overlay attaches it.
TLS is everywhere, without any exception in Core:
- **Browsers to the proxy:** publicly trusted ACME certificates.
- **API to PostgreSQL:** `verify-full` against the installation-private CA, passed as PEM text in
`EKKLESIA_DATABASE_CA`. PostgreSQL accepts only TLS 1.3 network connections.
- **API (and proxy) to object storage:** HTTPS on port 8443 with a certificate from the same CA.
Production storage must be https (ADR 0030). Core's S3 client uses Node's default trust store, so
Compose adds the CA with `NODE_EXTRA_CA_CERTS`.
### Where configuration and secrets live
| File (on the host) | Holds | Read by |
| --- | --- | --- |
| `deploy/.env` (from [`env/compose.env.example`](env/compose.env.example)) | Non-secret settings: the image, hostnames, paths, the edge subnet | Compose itself |
| `/etc/ekklesia/ekklesia.env` | Everything the API needs, including `DATABASE_URL` and the S3 keys | `api`, `migrate` |
| `/etc/ekklesia/postgres.env` | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | `postgres` |
| `/etc/ekklesia/object-storage.env` | The S3 key pair | `object-storage` |
| `/etc/ekklesia/tunnel.env` | `TUNNEL_TOKEN` | `tunnel` |
| `/etc/ekklesia/tls/` | The CA and server certificates and keys | mounted file by file |
The secret files are owned by root with mode 600, one per service, so no container receives another
service's credentials. None is committed, and `deploy/.env` is ignored by Git. `EKKLESIA_ENV_DIR`
and `EKKLESIA_TLS_DIR` in `deploy/.env` move the directories.
### First boot
1. **Get the image.** Build it ([Building](#building)) or pull it, and set `EKKLESIA_IMAGE` in
`deploy/.env`.
2. **Create the TLS material**, as root:
```sh
sudo sh deploy/scripts/init-tls.sh /etc/ekklesia/tls
```
It creates the CA once and a certificate for each of `postgres` and `object-storage`, gives each
key to its server's uid (PostgreSQL 70, SeaweedFS 1000) with mode 600, and prints the exact
`EKKLESIA_DATABASE_CA="…"` line. It never overwrites an existing key or certificate and never
prints a private key. `ca.key` is needed only to issue certificates: keep a copy offline.
3. **Generate secrets** with `sh deploy/scripts/gen-secrets.sh`, and paste each line into the file
named above it.
4. **Write the env files** from [`env/`](env/), then install each one owned by root with mode 600:
```sh
sudo install -d -m 755 /etc/ekklesia
sudo install -o root -g root -m 600 ekklesia.env /etc/ekklesia/ekklesia.env
```
`EKKLESIA_IDENTITY_BASE_URL` and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` are `https://` and your
Ekklesia hostname. `S3_DELIVERY_ENDPOINT` is `https://` and your storage hostname.
5. **Configure the proxy:** copy `Caddyfile.example` to `Caddyfile`, and set `EKKLESIA_HOSTNAME`,
`EKKLESIA_STORAGE_HOSTNAME`, and `EKKLESIA_ACME_EMAIL` in `deploy/.env`. Both hostnames must
resolve to this host with ports 80 and 443 reachable for HTTP-01, or see [DNS-01](#dns-01).
6. **Start the data services, then migrate explicitly:**
```sh
docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait postgres object-storage
docker compose run --rm migrate # plan: read-only
docker compose run --rm migrate node core/dist/cli/apply-migrations.js
```
Nothing ever applies migrations automatically: the API never does, and `migrate` runs only when
you run it.
7. **Start everything:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
8. **Check it:** `curl https://<hostname>/api/v1/health/ready` answers `{"status":"ready"}`, and
`docker compose run --rm migrate node core/dist/cli/doctor.js` reports `READY` (with warnings
for anything optional you left unconfigured, such as email).
### The initial owner
```sh
docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email [email protected]
```
It prompts for the password with hidden input and never accepts it on the command line. The owner
is created already verified, so they can sign in before any email relay exists
([ADR 0035](../docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)). To supply the
password from a secret manager instead, pipe it on standard input and use `-T` instead of `-it`.
### Upgrading
1. **Pull or build the new image**, and change `EKKLESIA_IMAGE` in `deploy/.env`. Keep the old tag.
2. **Back up** ([Backups](#backups)).
3. **Plan, review, then apply:** `docker compose run --rm migrate` with the new image, then
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
4. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
### Rolling back
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
the upgrade applied no migrations (the plan in step 3 showed none pending). Migrations are
forward-only. If any were applied, restore the backup taken in step 2 before starting the previous
image, because it was not built for the newer schema.
### Backups
Slice 6 adds the scheduled backup job and the restore procedure (ADR 0044, Backups). Until then,
take a `pg_dump` and a copy of every bucket before each upgrade by hand.
### Split Dashboard and API origins
By default one hostname serves the Dashboard (`/`), the API (`/api/*`), and displays (`/display/*`).
An installation may instead give the Dashboard its own hostname, for example `dash.example.org`
beside `api.example.org` ([ADR 0045](../docs/architecture/decisions/0045-split-dashboard-and-api-origins.md)).
The same image serves both; only configuration differs.
| Hostname | Serves | Refuses (`404`) |
| --- | --- | --- |
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
Displays live on the API's hostname (`https://api.example.org/display/<module>`), because their
device cookies are bound to it and their offline cache keeps only same-origin responses. That
hostname is what every screen and paired device depends on: choose it once.
To run split:
1. Copy `Caddyfile.split.example` to `Caddyfile` instead of `Caddyfile.example`.
2. In `deploy/.env`, set `EKKLESIA_HOSTNAME` to the API's hostname and `EKKLESIA_DASHBOARD_HOSTNAME`
to the Dashboard's.
3. In `ekklesia.env`, set `EKKLESIA_IDENTITY_BASE_URL` to `https://` and the API's hostname,
`EKKLESIA_DASHBOARD_ORIGIN` to `https://` and the Dashboard's, and
`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` to the Dashboard's origin. The API always trusts its own
origin, so it need not be listed.
**Both hostnames must belong to one registrable domain** (`dash.example.org` and `api.example.org`,
not `dash.example.org` and `api.example.net`). The session cookie stays host-only on the API's
hostname; browsers send it from the Dashboard only between origins of the same site. Doctor reports
the topology and warns when the two hostnames are not siblings.
The Dashboard learns the API's origin at run time: the API writes its identity base URL into the page
it serves (`<meta name="ekklesia-api-origin">`). Nothing about the topology is built into the image.
### Storage delivery: CORS is the deployment's
Browsers fetch objects directly from the storage hostname through signed, expiring URLs (ADR 0031).
**Which pages may read those bytes with `fetch()` is decided by the delivery layer, the proxy, and
never by the object store.** That policy therefore stays the same with SeaweedFS, R2, or any other
S3 backend behind it, and an installation that changes backends changes nothing about CORS.
The reference proxy's storage block:
- grants CORS only to the origins whose pages read objects with `fetch()`: the Ekklesia origin in the
same-origin topology; the API origin (displays) and the Dashboard origin (a module's preview) in
the split topology. An `<img>` needs no CORS and is unaffected;
- allows `GET` and `HEAD` only, and no request header beyond the CORS-safelisted ones, always with
`Vary: Origin`;
- answers preflight `OPTIONS` requests itself, so they never reach storage;
- removes the object store's own CORS headers. SeaweedFS's default reflects any origin and
advertises `PUT`, `POST`, and `DELETE`.
The proxy reaches storage over **HTTP/1.1 only**. Over HTTP/2, SeaweedFS answers a conditional
request with a `304` that declares a `Content-Length` it never sends. The proxy then resets the
browser's stream, and every reload of a cached image fails with `ERR_HTTP2_PROTOCOL_ERROR`.
Browsers still reach the proxy itself over HTTP/2 and HTTP/3.
### Behind an existing reverse proxy
Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of
your own, for example `compose.myproxy.yaml`:
```yaml
services:
api:
networks:
proxy: {}
environment:
# Exactly one trusted peer: your proxy, at an address that cannot change.
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: 10.0.100.254/32
object-storage:
networks:
proxy: {}
networks:
proxy:
external: true
name: your-proxy-network
```
Then make your proxy do what `Caddyfile.example` does:
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
- set no request-body limit below 32 MiB; the API enforces its own ceilings;
- forward the storage hostname to `https://object-storage:8443`, verifying it against
`/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host`
through unchanged**, or every signed URL fails (ADR 0031).
- reach `object-storage` over **HTTP/1.1** only, and own the storage hostname's CORS as described in
[Storage delivery](#storage-delivery-cors-is-the-deployments).
The proxy's address must be fixed and must never be handed to anything else. With Docker, give its
network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that
range, as `compose.proxy.yaml` does.
### Fixed addresses on `edge`
`compose.proxy.yaml` gives `edge` the subnet `10.249.250.0/24` and lets Docker assign dynamic
addresses only from its lower half (`10.249.250.0/25`). The proxy (`.254`) and the tunnel connector
(`.253`) take fixed addresses from the top of the upper half, which Docker never assigns to anything
else. That is what makes "only the proxy" and "only the connector" expressible as `/32` trust
entries. If the subnet collides with one on your host, change `EKKLESIA_EDGE_SUBNET`,
`EKKLESIA_EDGE_DYNAMIC_RANGE`, `EKKLESIA_PROXY_ADDRESS`, and `EKKLESIA_TUNNEL_ADDRESS` together,
keeping the fixed addresses at the top and outside the dynamic range.
### Cloudflare Tunnel
`compose.tunnel.yaml` runs `cloudflared` with a token, so the tunnel is managed in the Cloudflare
dashboard. No Cloudflare API is called from here.
1. In Cloudflare Zero Trust, under **Networks → Tunnels**, create a Cloudflared tunnel. Copy its
token into `/etc/ekklesia/tunnel.env` as `TUNNEL_TOKEN`.
2. Add a **public hostname** (shown as a *published application route* in newer dashboards) for
the Ekklesia hostname. Service type **HTTPS**, URL `proxy:443`.
3. Under that hostname's **TLS** settings, set **Origin Server Name** to the same Ekklesia hostname,
and leave **No TLS Verify** off. The connector then verifies the proxy's certificate for that name.
4. Add a public hostname for the storage hostname, the same way, with its own origin server name.
In the [split topology](#split-dashboard-and-api-origins), add one for the Dashboard's hostname
too: three in all, each with its own origin server name.
5. Start with all three files. The proxy honors `CF-Connecting-IP` only from the connector's fixed
address. From any other peer it is stripped.
The proxy's certificate must still be publicly trusted, and an HTTP-01 challenge cannot reach the
proxy through a tunnel, because Cloudflare terminates TLS at its edge. A tunnel-only installation
therefore uses [DNS-01](#dns-01). An installation that also serves local displays should give the
same hostnames a LAN path to the proxy (split-horizon DNS), so displays do not depend on the
Internet link.
### DNS-01
For an installation off the public Internet, LAN-only, or tunnel-only. DNS-01 needs a DNS provider
module, which the stock Caddy image does not contain:
1. Build a Caddy image with the module (for example
`xcaddy build --with github.com/caddy-dns/cloudflare`) and pin it by digest.
2. Override the proxy's `image:` in an overlay of your own. Give it `CLOUDFLARE_API_TOKEN` (a token
scoped to DNS edits for the zone) through a root-owned env file.
3. Enable the commented `acme_dns` line in your `Caddyfile`.
### Launch checklist
The ten items in [Security § launch checklist](../docs/development/security.md) mapped to this
deployment:
| # | Item | Where it is met |
| --- | --- | --- |
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
| 5 | Rotating forwarding headers cannot rotate the rate-limit bucket | The same, plus stripping `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; the API overwrites its internal header. Proven by the smoke test. |
| 6 | The proxy peer is the only trusted proxy | `compose.proxy.yaml` sets `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` to the proxy's fixed `/32`. |
| 7 | Proxy logs record no cookies, credentials, bodies, or forwarding chains | Caddy access logs are off in `Caddyfile.example`. If you enable them, exclude request headers. |
| 8 | PostgreSQL only from the API, over verified TLS | `postgres` is only on internal `data`; `pg_hba.conf` rejects non-TLS; `verify-full` with `EKKLESIA_DATABASE_CA`. Proven by the smoke test. |
| 9 | The deployed login and logout flow keeps its contract | Unchanged by the proxy; the smoke test signs in and out through it. |
| 10 | A supported way to create the first production identity | `create-initial-owner.js` through `migrate` ([The initial owner](#the-initial-owner)). Exercised by the smoke test. |
### Testing this deployment
- `pnpm deploy:config` validates the base, base + proxy, and base + proxy + tunnel with the example
env files. CI runs it in `pnpm ci:verify`.
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the whole deployment end to end, against a
platform-only image and a disposable PostgreSQL and SeaweedFS that exist only for that run. It
follows the first boot above and checks the following through the proxy:
- readiness and the Dashboard;
- a signed object URL fetched through the storage hostname, and the same image loaded twice over
HTTP/2, the second time as a conditional request that must be a clean `304`;
- storage CORS: exactly the allowed origins, `GET` and `HEAD` only, preflights answered by the
proxy, and none of the object store's own CORS headers;
- the client-address chain, both from an ordinary peer and from the tunnel connector's address;
- initial-owner sign-in and sign-out;
- that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a
wrong host name;
- Doctor.
`EKKLESIA_SMOKE_TOPOLOGY=split` runs the [split topology](#split-dashboard-and-api-origins)
instead: an image with the repository's module template installed as a test-only module, every
hostname's served and refused paths, exact CORS for the Dashboard's origin and none for others,
and then a real headless Chromium (pinned by digest, driven over the DevTools protocol) that signs
in on the Dashboard's hostname, checks the session cookie lives only on the API's, mounts the
module's UI from the API's origin and sees its own credentialed requests succeed, mounts its display
on the API's hostname with its service worker, and signs out.
CI runs both topologies on every change.
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run
has no public hostname. Never use it in an installation; `Caddyfile.example` does not contain it.
The smoke test uses no module, so it does not exercise realtime streams or large uploads. Through
this same proxy configuration, a scratch module confirmed both:
- a Server-Sent Event crossed the proxy in milliseconds;
- a 32 MiB streamed upload passed with and without `Content-Length`;
- one byte more was refused by the API's own `413 REQUEST_BODY_TOO_LARGE`.
## The production image
[`Dockerfile`](Dockerfile) builds **one image** for two jobs: the long-running `api` service, and
every operator-run command (migrations, Doctor, initial-owner creation, module enablement). There
is no second image and no second build.
### What it builds
The build context is an **already-assembled workspace**: this repository, plus zero or more module
packages already present in `modules/<id>`, plus a `pnpm-lock.yaml` describing exactly that set.
The Dockerfile clones nothing and chooses no modules. It builds what it is given. With no
`modules/` directory, it builds the platform alone; CI builds that case on every change.
Assembling the context (checking out the platform at a release tag, each module at a pinned tag or
commit, and the instance's own lockfile) belongs to the installation's instance composition, not to
this file.
| Stage | Does |
| --- | --- |
| `base` | `node:24.18.0-trixie-slim`, pinned by its multi-architecture index digest. Debian, so glibc, because module dependencies such as sharp ship glibc prebuilt binaries. Every stage starts here. |
| `toolchain` | Enables pnpm through Corepack, at exactly the root `packageManager` version. |
| `manifests` | Collects the lockfile, workspace configuration, `.npmrc`, and every workspace `package.json` at its repository path. |
| `build` | `pnpm install --frozen-lockfile`, then `pnpm boundaries`, `pnpm modules:validate`, `pnpm migrations:validate`, and `pnpm build`, against exactly the modules in the context. Never uses host `node_modules`: `.dockerignore` excludes them. |
| `prod-deps` | The same lockfile with `--prod`: production dependencies only. |
| `runtime` | Production dependencies plus built outputs, owned by root and run as the unprivileged `node` user (uid 1000). No pnpm, no compiler, no curl. |
The runtime keeps the repository layout at `/opt/ekklesia`, because that layout is how the platform
finds its files:
- the API takes the repository root as three directories above `apps/api/dist/`, and reads
`package.json`, `packages/module-sdk/package.json`, and `modules/` beneath it;
- every CLI takes its **working directory** as the root and also reads Core's migrations in
`core/drizzle/`, so commands must run from `/opt/ekklesia`, the image's `WORKDIR`;
- the Dashboard and display host files are read from `surfaces/shell/dist/browser/`;
- each module directory is copied whole, minus `node_modules`, because each manifest names its
own entrypoints, UI artifacts, and migrations directory.
### Building
```sh
docker build \
--file deploy/Dockerfile \
--build-arg VERSION=0.1.2 \
--build-arg REVISION="$(git rev-parse HEAD)" \
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
--tag ekklesia:0.1.2 \
.
```
The three build arguments become the OCI labels `org.opencontainers.image.version`, `.revision`,
and `.source`. They have no defaults: the repository cannot know an installation's Git host. The
image also records its base image name and digest. BuildKit is required (Docker's default builder);
set `DOCKER_BUILDKIT=1` if your Docker CLI has no buildx plugin.
No secret is a build argument, and none enters any layer. `.env` files are excluded from the build
context entirely, and no registry credentials are needed.
**A module missing a production dependency fails the build, not the API.** The runtime receives
production dependencies only, so a module that imports a package at run time but declares it only
as a development or peer dependency would build and then fail at startup. `pnpm boundaries` runs
in the build stage and rejects that module first
(`runtime-imports-are-production-dependencies`, see
[Repository boundaries](../docs/architecture/repository-boundaries.md)). The same applies to every
other boundary rule: a module the platform's own CI would reject cannot be built into an image.
**A platform-only image does not exercise native module dependencies.** With no modules in the
context, nothing like sharp is installed, so the CI image build proves nothing about them. An
instance build that includes such a module must smoke-test the built image itself: start it,
confirm `/api/v1/health/ready` answers `200`, and exercise the module's native code path (for
example, process one image).
### Configuration
Configuration comes **only from the container environment**, never from a file inside the image.
The image sets exactly three variables itself:
| Variable | Image value | Why |
| --- | --- | --- |
| `NODE_ENV` | `production` | Stated rather than relied on. Anything other than `development` already validates as production. |
| `EKKLESIA_API_HOST` | `0.0.0.0` | Reachable from other containers. Outside a container the platform default stays `127.0.0.1`. |
| `EKKLESIA_API_PORT` | `3000` | The platform default, made explicit; `EXPOSE 3000`. |
Everything else is the ordinary host configuration in
[API § Required configuration](../docs/development/api.md#required-configuration), supplied by the
reference deployment's `ekklesia.env`. `EKKLESIA_DATABASE_CA` is the CA's PEM text itself, on one
double-quoted line with `\n` escapes. Compose expands those escapes; plain `docker run --env-file`
does not, which is why every command here goes through Compose.
Never publish the API's port to the host or the Internet. Only the proxy publishes ports.
### Process, signals, and the read-only posture
`compose.yaml` runs `api` and `migrate` with `init: true`, `read_only: true`, `tmpfs: /tmp`,
`cap_drop: [ALL]`, and `no-new-privileges`. That is the supported production posture:
- **`init: true`** runs a minimal init as PID 1 that forwards signals and reaps children, so
`docker stop` reaches the API's own SIGTERM handler promptly. The API then drains in-flight
requests, stops modules in reverse order, and closes its pool.
- **Read-only:** the image was verified under exactly these flags. The API reached `healthy`, and
the migration and Doctor commands ran. Nothing wrote to `/tmp`, which stays mounted as headroom
for module code that needs scratch space. The installation, `/home/node` included, is not
writable, so anything that needs a writable path other than `/tmp` is a defect to report, not to
work around.
### Health
The image's `HEALTHCHECK` calls `GET /api/v1/health/ready` on `127.0.0.1` with Node's built-in
`fetch`, every 30 seconds after a 30-second start period, with three retries. Readiness means the
database answered within 2 seconds and the instance is not shutting down; see
[API § Health](../docs/development/api.md#health). Because it probes `127.0.0.1`, keep
`EKKLESIA_API_HOST` at `0.0.0.0` (or any address that includes loopback) inside the container. The
API trusts only the proxy's address for client-address headers, so the loopback probe is an
ordinary direct peer.
An external monitor can use the same endpoint through the proxy. Use `/api/v1/health/live` to ask
only whether the process is serving.
### Operator commands
The same image runs every CLI, directly with `node core/dist/cli/<command>.js`, through the
`migrate` service. The `pnpm` scripts of the same names rebuild first and read `.env`, so they are
for a development checkout, not for the image.
`migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check,
and its default command is the read-only plan. **Nothing applies migrations automatically.**
Run operator commands with the same Compose files as the stack. `COMPOSE_FILE` in `deploy/.env`
(set in [`env/compose.env.example`](env/compose.env.example)) does that for every plain
`docker compose` command below. It matters for Doctor in particular: Doctor checks the
client-address configuration of the process it runs in. The proxy overlay gives `migrate` the
API's trusted-proxy settings, so Doctor reports what the API runs. Without the overlay, Doctor
reports direct mode for an API that trusts its proxy. The running API's own value is shown by
`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.
| Task | Command (from `deploy/`) |
| --- | --- |
| Plan migrations (read-only) | `docker compose run --rm migrate` |
| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` |
| Doctor (read-only report) | `docker compose run --rm migrate node core/dist/cli/doctor.js` |
| Create the initial owner (interactive) | `docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email owner@example.org` |
| Enable or disable a module | `docker compose run --rm migrate node core/dist/cli/set-module-enabled.js --enable --module <id>` (or `--disable`) |
The plan, apply, Doctor, and initial-owner commands are exercised by the smoke test; module
enablement is not.
### Size
The platform-only image is about 378 MB: the Debian slim Node base (about 230 MB), production
dependencies (about 141 MB), and built outputs (about 6 MB). A rebuild that reuses a warm pnpm store
cache can come out about 21 MB larger. When esbuild's install script runs, it hard-links its
`bin/esbuild` to the native binary it ships beside it; when pnpm restores esbuild from the store's
cached post-install state instead, that file is a second full copy. Both are esbuild, one of the
unused tools below.
About 57 MB of the production dependencies are development tools that no runtime code imports:
`vitest`, `vite`, `drizzle-kit`, `tsx`, `esbuild`, and their native bindings. They arrive as
optional peer dependencies of Better Auth (and `esbuild` through the module UI builder), which pnpm
resolves because the workspace already has them. Excluding them means changing dependency
resolution and the lockfile, which is separate work.
+91
View File
@@ -0,0 +1,91 @@
# The reference TLS proxy overlay (ADR 0044's `proxy` service). Use it on top of the base:
#
# docker compose -f compose.yaml -f compose.proxy.yaml up -d
#
# Caddy terminates TLS with publicly trusted ACME certificates and publishes the deployment's only
# ports. It reaches `api` and `object-storage` on the `edge` network, which this overlay creates and
# attaches them to; `postgres` is never on it.
#
# Client addresses. Caddy is the only peer the API trusts to say who the client is: this overlay
# puts the API in trusted-proxy mode for exactly the proxy's fixed address, and the Caddyfile
# overwrites X-Forwarded-For with the one address Caddy resolved. Because the address must be
# fixed for "only the proxy" to be expressible, `edge` has an explicit subnet whose dynamic range is
# its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half,
# which Docker never hands to another container.
# Exactly one trusted peer: this proxy. Set from the same variable that fixes the proxy's address,
# so the two cannot drift apart. These override any value in ekklesia.env.
x-ekklesia-client-address: &ekklesia-client-address
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}/32
services:
proxy:
image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
ports:
# 80 answers ACME HTTP-01 challenges and redirects everything else to HTTPS. Set
# EKKLESIA_PUBLISH_ADDRESS to bind one host interface instead of all of them.
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTP_PORT:-80}:80"
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTPS_PORT:-443}:443"
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTPS_PORT:-443}:443/udp"
environment:
EKKLESIA_HOSTNAME: ${EKKLESIA_HOSTNAME:?EKKLESIA_HOSTNAME is required, for example ekklesia.example.org}
EKKLESIA_STORAGE_HOSTNAME: ${EKKLESIA_STORAGE_HOSTNAME:?EKKLESIA_STORAGE_HOSTNAME is required, for example storage.ekklesia.example.org}
# The split topology only (ADR 0045, Caddyfile.split.example), where EKKLESIA_HOSTNAME is the
# API's hostname and this is the Dashboard's. Caddyfile.example ignores it; the split
# configuration refuses to start without it.
EKKLESIA_DASHBOARD_HOSTNAME: ${EKKLESIA_DASHBOARD_HOSTNAME:-}
EKKLESIA_ACME_EMAIL: ${EKKLESIA_ACME_EMAIL:?EKKLESIA_ACME_EMAIL is required for ACME account notices}
EKKLESIA_TUNNEL_ADDRESS: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}
volumes:
- ${EKKLESIA_CADDYFILE:-./Caddyfile}:/etc/caddy/Caddyfile:ro
# The installation-private CA, so Caddy can verify object-storage's certificate.
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/etc/caddy/ekklesia-ca.crt:ro
- proxy-data:/data
- proxy-config:/config
networks:
edge:
ipv4_address: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}
depends_on:
api:
condition: service_healthy
object-storage:
condition: service_healthy
api:
networks:
edge: {}
environment: *ekklesia-client-address
# Operator commands (migrations, Doctor) run as `migrate`. It gets the API's client-address
# configuration too, so that Doctor checks what the API actually runs rather than an empty one.
migrate:
environment: *ekklesia-client-address
object-storage:
networks:
edge: {}
networks:
edge:
ipam:
config:
- subnet: ${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}
# Docker assigns dynamic addresses only from this range. Keep the fixed proxy and tunnel
# addresses outside it.
ip_range: ${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}
volumes:
proxy-data:
proxy-config:
+30
View File
@@ -0,0 +1,30 @@
# The Cloudflare Tunnel overlay (ADR 0044, optional ingress). Use it on top of base and proxy:
#
# docker compose -f compose.yaml -f compose.proxy.yaml -f compose.tunnel.yaml up -d
#
# `cloudflared` makes an outbound connection to Cloudflare and forwards visitors to the proxy over
# HTTPS. The tunnel is token-managed, so its public hostnames and origin settings live in the
# Cloudflare dashboard: each hostname's service is `https://proxy:443` with the matching origin
# server name and TLS verification left on (deploy/README.md, "Cloudflare Tunnel").
#
# The connector has a fixed address on `edge`. The Caddyfile trusts a client-address header
# (CF-Connecting-IP) only from that address, so no other peer can choose its own client address.
services:
tunnel:
image: cloudflare/cloudflared:2026.9.3@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c
restart: unless-stopped
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# The image's entrypoint is `cloudflared --no-autoupdate`; the token comes from TUNNEL_TOKEN.
command: ["tunnel", "run"]
env_file:
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env
networks:
edge:
ipv4_address: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}
depends_on:
- proxy
+153
View File
@@ -0,0 +1,153 @@
# Ekklesia's reference self-hosted deployment: the base project (ADR 0044).
#
# `api`, `postgres`, and `object-storage`, plus the operator-only `migrate` service. Nothing here
# publishes a port. Add `compose.proxy.yaml` for the reference TLS proxy, and `compose.tunnel.yaml`
# on top of that for a Cloudflare Tunnel connector; or attach `api` and `object-storage` to an
# existing proxy's network yourself (deploy/README.md).
#
# Non-secret settings come from `deploy/.env` (copy `env/compose.env.example`). Secrets live in
# root-owned, mode-600 files under EKKLESIA_ENV_DIR, one per service, so no container receives
# another service's credentials. TLS material comes from `scripts/init-tls.sh`.
name: ekklesia
x-ekklesia-hardening: &ekklesia-hardening
init: true
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
x-ekklesia-app: &ekklesia-app
<<: *ekklesia-hardening
image: ${EKKLESIA_IMAGE:?EKKLESIA_IMAGE is required, for example registry.example.org/ekklesia:0.1.0}
env_file:
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/ekklesia.env
environment:
# Core's object-storage client trusts Node's default CA set. Adding the installation-private CA
# to it, from the file below, is what lets the API verify `object-storage`'s certificate
# without any storage TLS exception. The database CA travels separately, as PEM text in
# EKKLESIA_DATABASE_CA, and is the only CA the database connection trusts.
NODE_EXTRA_CA_CERTS: /run/ekklesia/tls/ca.crt
volumes:
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/run/ekklesia/tls/ca.crt:ro
networks:
data: {}
storage: {}
egress: {}
services:
api:
<<: *ekklesia-app
restart: unless-stopped
# The image's own HEALTHCHECK (GET /api/v1/health/ready) applies unchanged.
depends_on:
postgres:
condition: service_healthy
object-storage:
condition: service_healthy
# Operator commands only. `profiles` keeps `docker compose up` from ever starting it; it runs only
# through `docker compose run --rm migrate …`. The default command is the read-only plan.
migrate:
<<: *ekklesia-app
profiles:
- ops
restart: "no"
healthcheck:
disable: true
command: ["node", "core/dist/cli/plan-migrations.js"]
depends_on:
postgres:
condition: service_healthy
object-storage:
condition: service_healthy
postgres:
image: postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
restart: unless-stopped
security_opt:
- no-new-privileges:true
env_file:
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/postgres.env
environment:
POSTGRES_INITDB_ARGS: --data-checksums --auth-host=scram-sha-256 --auth-local=trust
TZ: UTC
# TLS with the installation-private CA's server certificate for the name `postgres`, TLS 1.3
# only, and a host-based authentication file that rejects every non-TLS network connection.
# The API connects with verify-full; there is no plaintext path to fall back to.
command:
- postgres
- -c
- ssl=on
- -c
- ssl_cert_file=/run/ekklesia/tls/postgres.crt
- -c
- ssl_key_file=/run/ekklesia/tls/postgres.key
- -c
- ssl_min_protocol_version=TLSv1.3
- -c
- hba_file=/etc/ekklesia/pg_hba.conf
volumes:
- postgres-data:/var/lib/postgresql
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/postgres.crt:/run/ekklesia/tls/postgres.crt:ro
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/postgres.key:/run/ekklesia/tls/postgres.key:ro
- ./postgres/pg_hba.conf:/etc/ekklesia/pg_hba.conf:ro
# Only `data`: never a proxy-facing network, and no route out of the host.
networks:
data: {}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 5s
timeout: 5s
retries: 12
start_period: 20s
object-storage:
image: chrislusf/seaweedfs:4.28@sha256:d071979843142451d41566bc09f6adaa36902c112683d900a417247c82e9c3c1
restart: unless-stopped
security_opt:
- no-new-privileges:true
env_file:
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/object-storage.env
environment:
TZ: UTC
# The S3 API over HTTPS on 8443, with the installation-private CA's certificate for the name
# `object-storage`: production storage must be https (ADR 0030), for the API and for the proxy.
command:
- mini
- -dir=/data
- -s3.port.https=8443
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
- -s3.key.file=/run/ekklesia/tls/object-storage.key
volumes:
- object-storage-data:/data
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/object-storage.crt:/run/ekklesia/tls/object-storage.crt:ro
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/object-storage.key:/run/ekklesia/tls/object-storage.key:ro
networks:
storage: {}
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status"]
interval: 5s
timeout: 5s
retries: 18
start_period: 15s
networks:
# The database network: postgres, api, and migrate. Internal, so nothing on it reaches beyond the
# host and no proxy is ever attached to it.
data:
internal: true
# The storage network: object-storage, api, and migrate. Internal for the same reason; browsers
# reach storage only through a proxy that joins it on `edge`.
storage:
internal: true
# Outbound access for api and migrate (an SMTP relay, for example). Nothing listens on it.
egress: {}
volumes:
postgres-data:
object-storage-data:
+40
View File
@@ -0,0 +1,40 @@
# Non-secret settings for the reference deployment's Compose project. Copy to deploy/.env, which
# Compose reads automatically from this directory. Nothing here is a secret; secrets live in the
# per-service files under EKKLESIA_ENV_DIR.
# Which Compose files make up this installation, so that every plain `docker compose …` command
# run from this directory, operator commands through `migrate` included, sees the same services and
# settings as the running stack. Add compose.tunnel.yaml if you use the tunnel connector. Without
# the proxy overlay here, Doctor checks a `migrate` that lacks the API's client-address settings.
COMPOSE_FILE=compose.yaml:compose.proxy.yaml
# The image to run, by registry, name, and tag (or digest). Required.
EKKLESIA_IMAGE=
# Where the root-owned, mode-600 env files and the TLS directory live on the host.
EKKLESIA_ENV_DIR=/etc/ekklesia
EKKLESIA_TLS_DIR=/etc/ekklesia/tls
# --- compose.proxy.yaml ---------------------------------------------------------------------------
# The Ekklesia hostname and the storage hostname (signed object URLs). In the default same-origin
# topology (Caddyfile.example) EKKLESIA_HOSTNAME serves the Dashboard, the API, and displays.
EKKLESIA_HOSTNAME=
EKKLESIA_STORAGE_HOSTNAME=
# The split topology only (ADR 0045, Caddyfile.split.example): the Dashboard's own hostname, while
# EKKLESIA_HOSTNAME is the API's and the displays'. Both must share one registrable domain, for
# example dash.example.org and api.example.org. Leave empty for the same-origin topology.
EKKLESIA_DASHBOARD_HOSTNAME=
# Contact address for the ACME account (certificate expiry notices).
EKKLESIA_ACME_EMAIL=
# EKKLESIA_CADDYFILE=./Caddyfile
# EKKLESIA_PUBLISH_ADDRESS=0.0.0.0
# EKKLESIA_HTTP_PORT=80
# EKKLESIA_HTTPS_PORT=443
# The edge network. Change the subnet only if it collides with one already on the host. Docker
# assigns dynamic addresses only from EKKLESIA_EDGE_DYNAMIC_RANGE (the lower half); the proxy and
# the tunnel connector take fixed addresses from the upper half, so nothing else can ever hold them.
# EKKLESIA_EDGE_SUBNET=10.249.250.0/24
# EKKLESIA_EDGE_DYNAMIC_RANGE=10.249.250.0/25
# EKKLESIA_PROXY_ADDRESS=10.249.250.254
# EKKLESIA_TUNNEL_ADDRESS=10.249.250.253
+75
View File
@@ -0,0 +1,75 @@
# Ekklesia API and operator commands (`api` and `migrate`). Reference deployment, ADR 0044.
#
# Install as EKKLESIA_ENV_DIR/ekklesia.env (default /etc/ekklesia/ekklesia.env), owned by root with
# mode 600:
# install -o root -g root -m 600 env/ekklesia.env.example /etc/ekklesia/ekklesia.env
# Never commit the filled-in file. Generate secrets with scripts/gen-secrets.sh, and the database CA
# line with scripts/init-tls.sh.
#
# The image already sets NODE_ENV=production, EKKLESIA_API_HOST=0.0.0.0, and EKKLESIA_API_PORT=3000.
# Compose sets NODE_EXTRA_CA_CERTS. The proxy overlay sets the three EKKLESIA_CLIENT_ADDRESS_*
# variables. None of those belong here.
#
# Values that are part of the reference topology are filled in; everything installation-specific is
# blank. Full reference: docs/development/api.md#required-configuration.
# --- Database (ADR 0018) ------------------------------------------------------------------------
# The host must be exactly `postgres`: the server certificate is issued for that name and
# verify-full checks it. User, password, and database must match postgres.env. Percent-encode any
# reserved character in the password. Never add sslmode or another TLS parameter to this URL.
DATABASE_URL=postgresql://<user>:<password>@postgres:5432/<database>
EKKLESIA_DATABASE_TLS_MODE=verify-full
# The installation-private CA as PEM text (not a path), on one double-quoted line with \n escapes.
# scripts/init-tls.sh prints this exact line. It is the only CA the database connection trusts.
EKKLESIA_DATABASE_CA=
# --- Identity (ADR 0011, ADR 0018, ADR 0034, ADR 0045) -----------------------------------------
# The public https origin of the API, with no path: the Ekklesia hostname in the same-origin
# topology, the API's hostname in the split one. Production rejects http and loopback origins.
EKKLESIA_IDENTITY_BASE_URL=
# Comma-separated exact origins allowed to make credentialed requests. The API's own origin is
# always trusted and need not be listed. Same-origin topology: the base URL. Split topology: the
# Dashboard's origin.
EKKLESIA_IDENTITY_TRUSTED_ORIGINS=
# At least 32 random characters. Rotating it signs every user out.
EKKLESIA_IDENTITY_SECRET=
# Same-origin topology: leave unset; the Dashboard is served from the base URL (ADR 0044).
# Split topology: the Dashboard's https origin, which must also be in the trusted origins. Emailed
# verification and reset links land here (ADR 0045).
# EKKLESIA_DASHBOARD_ORIGIN=
# Production refuses public sign-up. Leave unset.
# EKKLESIA_PUBLIC_SIGNUP_ENABLED=
# --- Client address (ADR 0017) ------------------------------------------------------------------
# Set by compose.proxy.yaml. Only a base-only deployment behind its own proxy sets these here, with
# that proxy's fixed address as the only trusted entry (deploy/README.md):
# EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy
# EKKLESIA_CLIENT_ADDRESS_HEADER=x-forwarded-for
# EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES=<proxy address>/32
# --- Object storage (ADR 0030, ADR 0031) --------------------------------------------------------
# The API reaches SeaweedFS over HTTPS on the private network; production storage must be https.
S3_ENDPOINT=https://object-storage:8443
# The https origin browsers fetch signed object URLs from: the storage hostname on the proxy.
S3_DELIVERY_ENDPOINT=
# Must match object-storage.env.
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=us-east-1
S3_FORCE_PATH_STYLE=true
S3_BUCKET_PREFIX=ekklesia
# --- Outbound email (ADR 0037), optional --------------------------------------------------------
# Without a relay, email verification and password recovery cannot deliver. See
# docs/development/email.md.
# EKKLESIA_SMTP_HOST=
# EKKLESIA_SMTP_PORT=587
# EKKLESIA_SMTP_SECURITY=starttls
# EKKLESIA_SMTP_USERNAME=
# EKKLESIA_SMTP_PASSWORD=
# EKKLESIA_SMTP_FROM_ADDRESS=
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
# EKKLESIA_SMTP_HELO_NAME=
# --- Module secrets (ADR 0029), as installed modules declare them --------------------------------
# EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=
+4
View File
@@ -0,0 +1,4 @@
# SeaweedFS S3 credentials (`object-storage` only). Install as EKKLESIA_ENV_DIR/object-storage.env,
# owned by root with mode 600. Must match S3_ACCESS_KEY and S3_SECRET_KEY in ekklesia.env.
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
+8
View File
@@ -0,0 +1,8 @@
# PostgreSQL superuser for the reference deployment (`postgres` only). Install as
# EKKLESIA_ENV_DIR/postgres.env, owned by root with mode 600. Read only on the database's first
# start; changing it later does not change the stored password.
#
# Must match the user, password, and database in ekklesia.env's DATABASE_URL.
POSTGRES_USER=ekklesia
POSTGRES_DB=ekklesia
POSTGRES_PASSWORD=
+4
View File
@@ -0,0 +1,4 @@
# Cloudflare Tunnel connector token (`tunnel` only, compose.tunnel.yaml). Install as
# EKKLESIA_ENV_DIR/tunnel.env, owned by root with mode 600. Copy the token from the tunnel's page in
# the Cloudflare dashboard; it is the tunnel's credential.
TUNNEL_TOKEN=
+16
View File
@@ -0,0 +1,16 @@
# Host-based authentication for the reference deployment's PostgreSQL (ADR 0018, ADR 0044).
#
# Mounted read-only and selected with `-c hba_file=...` (deploy/compose.yaml), so it replaces the
# image's generated file entirely. Rules are matched top to bottom; the first match decides.
# TYPE DATABASE USER ADDRESS METHOD
# The container's own Unix socket: the image's first-start initialization and the health check.
# Nothing outside the container can reach it.
local all all trust
# Every network connection must be TLS (the server enforces TLS 1.3) and authenticate with SCRAM.
hostssl all all all scram-sha-256
# A network connection without TLS is refused outright, before authentication.
hostnossl all all all reject
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# Prints freshly generated secrets for the reference deployment's env files (ADR 0044). Writes
# nothing: paste each line into the file named above it, then install that file root-owned with
# mode 600.
#
# sh deploy/scripts/gen-secrets.sh
#
# Every value is URL-safe (hexadecimal), so the database password needs no percent-encoding in
# DATABASE_URL.
set -eu
command -v openssl >/dev/null 2>&1 || {
echo "gen-secrets: openssl is required." >&2
exit 69
}
hex() {
openssl rand -hex "$1"
}
postgres_password=$(hex 24)
s3_access_key=$(hex 10)
s3_secret_key=$(hex 32)
cat <<EOF
# ekklesia.env
EKKLESIA_IDENTITY_SECRET=$(hex 32)
DATABASE_URL=postgresql://ekklesia:$postgres_password@postgres:5432/ekklesia
S3_ACCESS_KEY=$s3_access_key
S3_SECRET_KEY=$s3_secret_key
# postgres.env
POSTGRES_PASSWORD=$postgres_password
# object-storage.env
AWS_ACCESS_KEY_ID=$s3_access_key
AWS_SECRET_ACCESS_KEY=$s3_secret_key
EOF
+148
View File
@@ -0,0 +1,148 @@
#!/bin/sh
# Creates the installation-private CA (once) and the two server certificates the reference
# deployment needs (ADR 0044): `postgres` for the database and `object-storage` for SeaweedFS.
#
# sudo sh deploy/scripts/init-tls.sh /etc/ekklesia/tls
#
# Idempotent. An existing key or certificate is never overwritten: delete a file deliberately to
# reissue it. Ownership and modes are re-applied on every run, which needs root; a non-root run
# creates what is missing and then stops before that step, saying so. No private key is ever
# printed.
#
# Output (in the directory given):
# ca.key the CA's private key. Root only. Needed only to issue certificates; keep a
# copy offline and consider removing it from the server afterwards.
# ca.crt the CA certificate. Public. Mounted into api, migrate, and proxy.
# postgres.crt/.key for the name `postgres`; the key is owned by PostgreSQL's uid (70).
# object-storage.crt/.key for the name `object-storage`; the key is owned by SeaweedFS's uid (1000).
set -eu
umask 077
usage() {
echo "Usage: sh init-tls.sh <output directory>" >&2
exit 64
}
[ "$#" -eq 1 ] || usage
out=$1
case "$out" in
-*) usage ;;
esac
# The uids the two images run their servers as: postgres:18.4-alpine3.24 (postgres) and
# chrislusf/seaweedfs:4.28 (seaweed). Each server refuses or fails to read a key it cannot own.
POSTGRES_UID=${EKKLESIA_POSTGRES_UID:-70}
SEAWEED_UID=${EKKLESIA_SEAWEED_UID:-1000}
CA_DAYS=3650
SERVER_DAYS=825
mkdir -p "$out"
chmod 700 "$out"
created=""
# Only generating needs openssl. A later root-only pass that applies ownership to files that already
# exist runs without it, in any image that has a POSIX shell.
require_openssl() {
command -v openssl >/dev/null 2>&1 || {
echo "init-tls: openssl is required to create keys and certificates." >&2
exit 69
}
}
note_created() {
created="$created $1"
}
# A file this script would create already exists: keep it, and say so.
exists() {
if [ -e "$out/$1" ]; then
echo "init-tls: keeping existing $out/$1"
return 0
fi
return 1
}
if ! exists ca.key; then
require_openssl
openssl ecparam -name prime256v1 -genkey -noout -out "$out/ca.key"
note_created ca.key
fi
if ! exists ca.crt; then
require_openssl
openssl req -x509 -new -sha256 -days "$CA_DAYS" -key "$out/ca.key" -out "$out/ca.crt" \
-subj "/CN=Ekklesia installation CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash"
note_created ca.crt
fi
# issue <name>: a key and a certificate whose only subject alternative name is <name>, the Compose
# service name the client verifies against.
issue() {
name=$1
if ! exists "$name.key"; then
require_openssl
openssl ecparam -name prime256v1 -genkey -noout -out "$out/$name.key"
note_created "$name.key"
fi
if ! exists "$name.crt"; then
require_openssl
extensions=$(mktemp)
request=$(mktemp)
trap 'rm -f "$extensions" "$request"' EXIT
cat >"$extensions" <<EOF
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=serverAuth
subjectAltName=DNS:$name
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid
EOF
openssl req -new -sha256 -key "$out/$name.key" -subj "/CN=$name" -out "$request"
openssl x509 -req -sha256 -days "$SERVER_DAYS" -in "$request" \
-CA "$out/ca.crt" -CAkey "$out/ca.key" -CAcreateserial -CAserial "$out/ca.srl" \
-extfile "$extensions" -out "$out/$name.crt" 2>/dev/null
rm -f "$extensions" "$request"
trap - EXIT
note_created "$name.crt"
fi
}
issue postgres
issue object-storage
# Public material is world-readable; private keys are readable only by the process that needs them.
chmod 644 "$out/ca.crt" "$out/postgres.crt" "$out/object-storage.crt"
chmod 600 "$out/ca.key" "$out/postgres.key" "$out/object-storage.key"
[ -e "$out/ca.srl" ] && chmod 600 "$out/ca.srl"
if [ -n "$created" ]; then
echo "init-tls: created$created in $out"
else
echo "init-tls: nothing to create in $out"
fi
if [ "$(id -u)" -ne 0 ]; then
echo "init-tls: ownership not applied: re-run as root to give postgres.key to uid $POSTGRES_UID and object-storage.key to uid $SEAWEED_UID." >&2
exit 77
fi
chown root:root "$out" "$out/ca.key" "$out/ca.crt" "$out/postgres.crt" "$out/object-storage.crt"
[ -e "$out/ca.srl" ] && chown root:root "$out/ca.srl"
chown "$POSTGRES_UID:$POSTGRES_UID" "$out/postgres.key"
chown "$SEAWEED_UID:$SEAWEED_UID" "$out/object-storage.key"
# The directory itself must be traversable by the two server uids for their bind mounts.
chmod 755 "$out"
echo "init-tls: postgres.key is owned by uid $POSTGRES_UID and object-storage.key by uid $SEAWEED_UID (mode 600)."
echo
echo "Set EKKLESIA_TLS_DIR=$out in deploy/.env, and put this line in ekklesia.env (the CA"
echo "certificate is public; this is not a secret):"
echo
printf 'EKKLESIA_DATABASE_CA="%s"\n' "$(awk 'BEGIN { ORS = "\\n" } { print }' "$out/ca.crt")"
@@ -1,6 +1,6 @@
# 0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources
- Status: Proposed
- Status: Accepted
- Date: 2026-10-01
- Supersedes: none
- Related: [ADR 0003](0003-development-infrastructure.md),
@@ -48,7 +48,7 @@ One host, one Compose project, four long-running services on a private network:
| Service | Role |
| --- | --- |
| `proxy` | Caddy. Terminates TLS, serves the only published ports (443, 80 for redirect), routes `/api/*`, `/display/*`, `/assets/*` and the Dashboard to `api`, and a separate storage origin to `object-storage`. |
| `proxy` | Caddy. Terminates TLS, serves the only published ports (443; and 80, which answers ACME HTTP-01 challenges and redirects everything else to HTTPS), routes `/api/*`, `/display/*`, `/assets/*` and the Dashboard to `api`, and a separate storage origin to `object-storage`. |
| `api` | The `apps/api` image: Core, the enabled modules, and the built Dashboard and display host. Listens on the private network only. |
| `postgres` | PostgreSQL 18 with TLS enabled, using a server certificate issued by an installation-private CA. |
| `object-storage` | SeaweedFS, the same image family as development, reachable by browsers only through the proxy. |
@@ -65,21 +65,31 @@ server. The proxy never serves files from disk.
### TLS everywhere, without weakening ADR 0018
- **Browser-facing:** the proxy obtains publicly trusted certificates through ACME. The reference
configuration uses the HTTP-01 challenge; the DNS-01 challenge is documented for installations
that keep Ekklesia off the public Internet. A LAN-only installation can still hold a real
certificate for an internal hostname this way.
configuration uses the HTTP-01 challenge on port 80; the DNS-01 challenge is documented for
installations that keep Ekklesia off the public Internet. A LAN-only installation can still hold a
real certificate for an internal hostname this way. DNS-01 requires a Caddy build that includes a
DNS provider module, which the stock Caddy image does not; such an installation builds and pins
its own proxy image. A tunnel-only installation needs DNS-01 too: Cloudflare terminates TLS at its
edge, so an HTTP-01 challenge cannot reach the proxy through the tunnel.
- **Tunnel ingress (optional):** an installation may publish the proxy through an outbound tunnel
(the reference documents `cloudflared`) instead of opening inbound ports. The tunnel connector
is a separate Compose service that connects to the proxy over HTTPS and verifies the proxy's
certificate. The proxy accepts a tunnel-supplied client-address header **only** from the
connector's network identity. It overwrites the single forwarding header it sends to `api`, and
`api` trusts only the proxy. An installation that serves local displays should give the same
hostname a LAN path to the proxy (split-horizon DNS), so displays do not depend on the
Internet link.
certificate. With a token-managed tunnel, those origin settings (the service `https://proxy:443`,
the origin server name, and TLS verification left on) live in the Cloudflare dashboard, not in the
repository; the deployment README documents them. The proxy accepts a tunnel-supplied
client-address header **only** from the connector's network identity. It overwrites the single
forwarding header it sends to `api`, and `api` trusts only the proxy. An installation that serves
local displays should give the same hostname a LAN path to the proxy (split-horizon DNS), so
displays do not depend on the Internet link.
- **Database:** the deployment's setup script creates an installation-private CA once, issues a
server certificate for the `postgres` service name, and gives the API only the CA certificate
(`EKKLESIA_DATABASE_CA`). The API connects with `verify-full`. No plaintext or encryption-only
exception is added to Core.
- **Object storage:** production storage must be `https` (ADR 0030), so the API reaches SeaweedFS
over HTTPS on the private network as well. The same setup script issues a server certificate for
the `object-storage` service name from the same CA. Core's storage client trusts Node's default CA
set, so the API adds the installation CA to it with `NODE_EXTRA_CA_CERTS`. No storage TLS
exception is added to Core either.
### Object storage
@@ -134,8 +144,9 @@ an installation is treated as in production.
### Secrets
Secrets are supplied to Compose from a root-owned environment file on the host, never committed.
This includes `EKKLESIA_IDENTITY_SECRET`, database and storage credentials, SMTP credentials, and
Secrets are supplied to Compose from root-owned environment files on the host, never committed:
one per service, so no container receives another service's credentials. They include
`EKKLESIA_IDENTITY_SECRET`, database and storage credentials, SMTP credentials, and
`EKKLESIA_MODULE_SECRET__*` values. A secret manager is not required.
## Consequences
@@ -0,0 +1,233 @@
# 0045: The Dashboard and the API may live on separate origins
- Status: Accepted
- 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.
@@ -0,0 +1,110 @@
# 0046: Ekklesia's branding is never forced on an installation
- Status: Proposed
- Date: 2026-10-03
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0034](0034-host-owned-browser-authentication.md),
[ADR 0037](0037-host-provided-outbound-email.md),
[ADR 0038](0038-email-verification-and-password-recovery.md),
[ADR 0039](0039-shared-design-tokens-and-elements.md),
[ADR 0043](0043-display-offline-code-fallback.md),
[ADR 0045](0045-split-dashboard-and-api-origins.md)
## Context
Ekklesia is software a church runs for its own people. The people who use an installation (members,
visitors, volunteers running a screen) know the church, not the software behind it, and the church
decides how it presents itself to them. Today the platform names itself in places those people see:
the sign-in screen asks them to "Sign in to Ekklesia", the Dashboard's heading and page title read
"Ekklesia", emails ask them to verify "your Ekklesia email address", and an offline screen in the
sanctuary says it is "waiting for Ekklesia".
None of those were decided; they accumulated. This records the rule they should have followed, so
every later screen, message, and module is held to it.
## Decision
### The principle
**Ekklesia's branding is never forced on an installation.** Anything a member, visitor, or volunteer
can see is configurable by the installation and removable entirely. That includes, and is not
limited to:
- page titles, headings, and the name of the application in a browser tab, an installed web app, or
a bookmark;
- sign-in, verification, recovery, and every other authentication screen;
- emails: the sender's display name, the subject, and the body;
- display screens, pairing screens, and offline and failure screens on a display;
- error pages and error messages a person reads;
- application names and icons, and any "powered by" or attribution text.
"Removable entirely" means an installation can show its own name, or no product name at all, and in
either case no screen or message a person sees names Ekklesia.
### What is exempt
Internal identifiers are not branding and stay as they are: package names, module ids, URL paths
(`/api/v1/…`, `/assets/ekklesia-dashboard/…`), HTTP header names, environment variable names, cookie
names, CSS custom property names, element ids and `data-` attributes, storage, cache, and database
names, and log field names. A person can find them only by opening developer tools or reading
configuration, and renaming them would break installations for no one's benefit.
Tooling an operator runs to administer the installation (the command-line tools, Doctor, server logs,
and startup messages) names the software being operated, as a package name does, and is exempt too.
So are messages written only for developers: thrown errors that reach a console and never a page, and
build-time diagnostics for module authors.
### How installations configure it
The platform takes the installation's name from configuration, once, and uses it everywhere a
person-facing string would otherwise have said "Ekklesia". When no name is configured, the wording is
neutral ("Sign in", "Verify your email address", "Waiting for the server") rather than falling back to
"Ekklesia". The default installation therefore names nothing, which is what makes the principle
checkable: a test can render every person-facing screen and message with no name configured and fail
on any occurrence of "Ekklesia".
Browser pages receive the name the way they receive the API origin (ADR 0045): the API renders it
into the page it serves at startup, so the image stays generic and nothing is decided at build time.
An email's sender display name is the existing `EKKLESIA_SMTP_FROM_NAME` (ADR 0037); its subject and
body use the same installation name.
Visual identity follows the same rule. The platform ships no logo, favicon, or icon today; any it
ships later must be replaceable by the installation and removable entirely, and must not appear by
default. Colours and type are the installation's theme (ADR 0039), not a mark.
### It applies to modules
A module's user-visible text follows the same principle. A module that names its host ("Open the
Ekklesia Dashboard") also ties itself to one host, which ADR 0021 already asks modules not to do.
Modules refer to the host generically ("the Dashboard") or not at all.
## Consequences
- Every person-facing string that names the platform today becomes either the configured name or
neutral wording. An implementation slice makes that change and adds the test that keeps it true.
- New screens, emails, and modules are reviewed against this rule; "Ekklesia" in a person-facing
string is a defect.
- Internal identifiers, operator tooling, and developer diagnostics keep the name, so nothing
operational is renamed.
- An installation that wants to credit Ekklesia can do so in its own words; the platform never
inserts that credit.
## Alternatives
- **Keep "Ekklesia" as the default and let installations override it.** Rejected: an installation
that never thought to configure it would show the software's name to its people, which is forcing
it by default, and "no occurrence of Ekklesia" could not be tested in the default state.
- **Rename internal identifiers too.** Rejected: invisible to the people this protects, and costly
to every installation and module.
- **A configurable "powered by" line, on by default.** Rejected for the same reason as the first
alternative.
- **Per-string customization of every message.** Not needed for this principle: one installation
name and neutral wording remove the product's name everywhere. Richer copy customization, if
wanted, is separate work.
## Deferred
- The implementation: the setting, its delivery to pages and emails, the rewording, and the
regression test.
- Installation-supplied logos, icons, and favicons, and a web app manifest.
- Translation and per-string copy customization.
@@ -0,0 +1,256 @@
# 0047: Every module proves itself inside Ekklesia, with one harness the platform publishes
- Status: Proposed
- Date: 2026-10-05
- Supersedes: none
- Related: [ADR 0016](0016-module-database-access.md),
[ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0028](0028-module-operational-lifecycle.md),
[ADR 0030](0030-host-provided-module-object-storage.md),
[ADR 0033](0033-display-kiosk-host-contract.md),
[ADR 0036](0036-managed-device-identity-and-pairing.md),
[ADR 0041](0041-module-browser-contributions.md),
[ADR 0043](0043-display-offline-code-fallback.md),
[ADR 0044](0044-self-hosted-reference-deployment.md),
[ADR 0045](0045-split-dashboard-and-api-origins.md)
## Context
Modules are independently versioned products (ADR 0021). They live in their own repositories, and
none of them can be built, run, or rendered without Ekklesia. The first production module, Signage,
reached a church with three defects that only show when it runs **inside** Ekklesia, in a real
browser:
- every heartbeat its display sent was refused with a 400, because the display and the server
disagreed about `null`;
- a display that failed one request at start-up never recovered without a reload;
- its media cache leaked one blob per slide per refresh, until the browser refused the display's own
requests.
Signage had unit tests, browser tests against a stubbed host, and no CI. Every test passed, because
each half was tested against its own idea of the other, and nothing ever ran the module in the
platform it ships into.
The platform's own deployment smoke (ADR 0044, ADR 0045) does run a module in a real browser, but
only the repository's template, and only far enough to mount it. The instance repository runs the
smoke against its real modules, but only at release time, after a module has already been tagged.
Each module repository that wants more than that has to assemble the platform, build an image,
start a deployment, and drive a browser itself. Signage is about to do exactly that by hand. The
second module, Classroom, would copy it, and the copies would drift from the platform they test.
## Decision
### The platform publishes a versioned module harness
Each platform release publishes one OCI image, `ekklesia-module-harness:<platform version>`. It is
built from the platform at that release tag by the platform's own release CI, and pushed to the
organization's registry. A version is never overwritten. Modules reference it **by digest**.
The harness contains the platform source at that version: its `deploy/` directory, its Dockerfile,
its lockfile, and its smoke scripts. It also contains the pinned Node.js and pnpm, the browser
driver, and the pinned headless Chromium image reference. Given a module checkout, it:
1. **assembles** the module into the platform at `modules/<id>`, exactly as an instance does (ADR
0044), and installs with `--frozen-lockfile`;
2. runs the platform's own gates against that set: `pnpm boundaries`, `modules:validate`,
`migrations:validate`, and the build;
3. **builds** the deployment image from the assembled workspace with the platform's Dockerfile;
4. starts the **reference deployment** in the split topology (ADR 0045), with a disposable
PostgreSQL and object store, Caddy's internal CA, and freshly generated secrets;
5. plans, then **explicitly applies**, migrations against that fresh database. This is the only
database the harness ever migrates;
6. creates the initial owner, grants the module's declared permissions to a test person, and
**mounts every UI surface the manifest declares**, in headless Chromium. That covers:
- the primary UI in the Dashboard;
- the display, paired as a device with no human session (ADR 0036);
- each browser contribution, whenever the module that hosts it is present (ADR 0041);
7. runs a short **display soak**, and the **no-dead-ends** injections below;
8. tears everything down: containers, networks, volumes, and the work directory.
It **fails** on any of the following:
- a `console.error` or uncaught exception on any page;
- an unhandled promise rejection;
- any 4xx or 5xx response that the harness did not itself expect. Expected ones are listed in the
harness, for example a display's descriptor before it is paired;
- a page that never mounts;
- a failed or refused request (CORS, blocked, network error);
- a soak leak.
Each failure line names the surface, the request path (never its query), and the step.
### The display soak and the no-dead-ends checks are generic
These checks come from what Signage got wrong, and they apply to any module with a display.
- **Soak**, two minutes by default. The display stays mounted with no remount and no offline
indicator. Its realtime streams stay healthy. Live object URLs stay bounded. JavaScript heap
growth, measured over DevTools, stays within a fixed ratio.
- **No dead ends.** One at a time, the harness refuses the first request to each module route the
display calls at start-up, and the first realtime connection. Within a bounded time and with no
reload, the display must make that request again and succeed. A display has nobody to press
Retry.
Module-specific behaviour, such as Signage's heartbeat being recorded, is not guessed by the
harness. A module declares it (below).
### A module is tested against both ends of its compatibility range
A module's CI runs the harness twice: against the **lowest** and the **newest** published platform
version within its manifest's `compatibility.platform` range. Testing every version in between adds
cost and finds little. Testing only the newest misses the floor the manifest promises to work on.
The two versions, and their harness digests, are pinned in the module's repository. The harness
refuses to run if:
- a pinned version lies outside the module's range;
- the lowest pinned version is not the range's floor.
Moving to a newer platform is a deliberate, reviewed pin change, as an instance's is (ADR 0044). It
is never a moving tag.
A frozen install of the module assembled into each of those two platforms needs a lockfile per
pinned version. The module keeps them (`ci/lock/<platform version>.yaml`), and the harness provides
the one command that regenerates them inside its own pinned Node.js. A stale lockfile fails the
frozen install, which is the signal to regenerate it in a reviewed change.
### Modules declare extra smoke steps, beside their manifest rather than in it
A module may add steps of its own in `ekklesia.harness.json`, next to `ekklesia.module.json`. Steps
are declarative first:
- open a surface;
- wait for a test id;
- call one of the module's routes as a named role, expecting a status;
- soak for N minutes;
- inject a failure into a named route;
- check a value in the module's own table, through its own route.
For what a declaration cannot express, a step may name a driver script in the module's repository.
The harness runs that script with the same DevTools client and the same failure rules.
**This is deliberately not a manifest field.** Manifest versions are strict, hard cutovers that every
host validates (ADR 0033). A test step is not a runtime contract, and a host must never read one.
Putting steps in the manifest would force a manifest version bump on every module for something
that has no meaning outside CI.
### Later: the harness checks what a module actually touches against what it declares
In a later phase, the harness records what the module does while it runs:
- the tables and schemas it reads and writes;
- the buckets it uses;
- its outbound network connections, with the egress network blocked and every attempt logged.
It compares them with what the manifest declares (ADR 0016, ADR 0030). A module that touches what it
did not declare fails. This is the module-trust check. The egress half needs a manifest declaration
of outbound destinations that does not exist yet, and is a separate decision. This ADR fixes only
the harness's shape, so that the recording phases can be added without changing how modules call
it.
### The template's workflow stays short, and calls the harness by digest
`templates/module` ships a workflow that:
- checks out the module with `persist-credentials: false` and pinned actions;
- runs the module's own unit tests, typecheck, and lint;
- sweeps stale CI stacks from the runner, then runs the harness, once per pinned platform version,
with one `docker run` of the harness image **by digest**, and finally, always, removes this job's
own stacks. The module directory is mounted read-only, and the runner's Docker is used exactly
as the platform's smoke uses it.
It references no secret, needs no registry credential (the harness image is public within the
organization), and uses no `pull_request_target`. The trusted-code-only rule for self-hosted runners
applies to module repositories exactly as it does to the platform (see `docs/development/security.md`).
**Why `docker run`, and not a reusable workflow or a composite action.** The server runs Gitea
1.27.2. (This proposal first assumed 1.24.2; the reasons below hold on the actual version.)
- Its runner can call a reusable workflow from another repository by cloning that repository, and
since 1.26 that includes private repositories (go-gitea/gitea#32562). That support is still thinly
documented, and has known problems in practice.
- A reusable workflow would make the harness's behaviour depend on how this server and runner
version expand it. An image runs the same everywhere it runs.
- Composite actions from another repository work through absolute `uses:` URLs. That would still
wrap the same `docker run`, adding a second pinned reference and nothing else.
An image digest is the one reference that is equally immutable, equally reviewable, and works
unchanged on any Gitea release and on GitHub Actions. A composite action can be added later as a
convenience over the same image, without changing what a module pins.
### Nothing the harness starts outlives the job that started it
The harness starts Docker stacks on a shared runner, and a runner can kill a job outright: a
cancelled or superseded job (`concurrency` cancels superseded runs on this server), or the runner's
own time limit. A script's own cleanup trap does not run then. The platform's CI leaked a whole
migration stack that way, and it ran until someone removed it by hand. So the harness follows the
platform's rule (`scripts/ci-sweep.sh`, `docs/development/security.md`):
- everything it starts is named and labeled as a CI stack of its run, attempt, and job key;
- it removes its own stacks in a trap;
- the template workflow runs the harness's **stale sweep** before the harness starts, and its
**job sweep** in a final `if: always()` step, so a killed run's stacks are removed by that run's
last step, or failing that by the next job on the runner.
The sweep ships inside the harness image, so a module never copies it.
### Publishing follows the instance's release pattern
The harness image is pushed only by a `publish` job that runs on `v*` tags, after the platform's CI
has built and tested that exact image (checked by image ID). That job:
- uses a write-only package token, the only secret in the platform's workflow;
- refuses to overwrite an existing version;
- prints the digest.
Pull requests and branch builds build the harness and run it against the template, but never push.
The registry is reached exactly as the instance's publish job reaches it.
### Adoption
- **Signage** uses an interim CI job until the first harness is published. That job assembles
Signage into a pinned platform commit and runs the platform's display-device smoke scenarios.
Signage switches to the harness as soon as one is published.
- **Classroom** follows.
- **The template** carries the workflow from the first harness release on, so every new module has
it from its first commit.
## Consequences
- A module cannot reach a release without having run inside Ekklesia, in a real browser, at both
ends of the platform range it claims.
- The defects that reached the first production Signage deployment fail a module's CI instead:
refused requests, dead ends, leaks, and console errors.
- The platform owns one harness, versioned with itself. Modules do not copy deployment scripts that
then drift.
- Every module's CI needs Docker on its runner, and takes minutes rather than seconds. The harness
runs as separate jobs from the unit tests, so a quick failure still reports quickly.
- The platform's release process gains one published artifact, one credential, and one more failure
mode at release time.
- A module now carries a lockfile for each pinned platform version. The harness makes regenerating
them one command, and a stale one fails visibly.
## Alternatives
- **Each module keeps its own copy of the smoke scripts.** Rejected. That is today's situation in
embryo, and it drifts.
- **A reusable workflow in the platform repository.** Rejected for now, for the reasons above: thin
support in practice, and a harness whose behaviour depends on whichever runner and server version
happens to expand it.
- **Publish the harness as an npm package.** Rejected. The harness needs Docker, a browser, and the
platform's deployment files anyway, so a package would ship a fraction of it and assume the rest.
- **Test only the newest platform version.** Rejected. A module's range floor would go untested
until an installation that has not upgraded meets it.
- **Put smoke steps in the manifest.** Rejected. Manifest versions are hard cutovers with runtime
meaning, and test steps have neither.
- **Run the harness in the platform's CI against every known module.** Rejected. The platform would
need every module's source and would fail on modules it does not own. The direction is module
tests platform, not the reverse.
## Deferred
- The data-access and egress recording phases, and the manifest declaration egress needs.
- A composite action over the harness image.
- Running the harness against a module's declared dependencies at their own pinned versions, for
contributions hosted by another module.
- Visual regression of mounted surfaces.
+4 -1
View File
@@ -52,4 +52,7 @@ ADRs capture durable choices that affect more than one package or phase.
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.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
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Proposed
- [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) — Accepted
- [0046: Ekklesia's branding is never forced on an installation](0046-installation-owned-branding.md) — Proposed
- [0047: Every module proves itself inside Ekklesia, with one harness the platform publishes](0047-module-ci-harness.md) — Proposed
+9 -1
View File
@@ -41,7 +41,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`,
`surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are
browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on
it — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
it and React — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
compile under the exact browser project a module uses. Both are also pinned at the bottom of the
presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation
layer, an API/auth client, or the backend contracts barrel, because a design package that could
@@ -134,6 +134,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
- Workspace package consumers use declared `exports`, not private subpaths.
- Every internal dependency uses a `workspace:` range and every imported workspace package is
declared by the consumer.
- Code a workspace package ships (its `src/` and `bin/`, excluding tests and declaration files)
declares every runtime import in `dependencies` or `optionalDependencies`. A peer declaration may
stay beside it, but never replaces it: a workspace package is linked into its consumers as a
directory, so its imports resolve from its own `node_modules`, where a consumer never satisfies a
peer, and a production install (`pnpm install --prod`, as the production image runs) omits
development dependencies. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing
`react`, because the automatic JSX runtime imports `react/jsx-runtime` implicitly
(`runtime-imports-are-production-dependencies`).
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
- Workspace package names are unique and shared packages declare a recognized architectural role.
- Circular source and workspace-package dependencies are rejected.
+141 -9
View File
@@ -62,7 +62,7 @@ No real values are shown here — configure per environment and never commit the
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
| `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. |
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. |
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. The base URL's own origin is always trusted as well and need not be listed ([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). |
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
@@ -77,6 +77,10 @@ No real values are shown here — configure per environment and never commit the
| `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. |
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), with the API's origin written into it. Served from the API's origin, or from the Dashboard's own hostname through the proxy in the split topology. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
| `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). |
| `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
| `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
@@ -134,6 +138,77 @@ a new raw token when asked to revoke other sessions. The principal response neve
contains email, name, image, session id or expiry, session token, account/provider details, or any
role, permission, membership, or volunteer data.
## Dashboard host
`apps/api` serves the built Dashboard itself
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). In the default
same-origin topology it does so from the same origin as `/api`: `EKKLESIA_DASHBOARD_ORIGIN` stays
unset, the session cookie stays host-only, and no second static server or proxy file root is
needed. In the split topology
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the proxy forwards
the Dashboard's own hostname to the same two routes; the API does not inspect `Host`. It works
exactly like the display host:
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the
exact URLs of two built files, `dist/browser/index.html` and
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256
strong `ETag` for each.
- **The API's origin is written in at startup.** The built page carries one empty
`<meta content="" name="ekklesia-api-origin" />`. Before computing the page's `ETag`, `startApi()`
fills it with the identity base URL's origin, which is where `/api` lives in either topology. The
shell reads it before rendering, uses the page's own origin when it is empty, and fails closed,
making no request, when it is anything but a canonical `http(s)` origin. Nothing about the topology
is built into the bundle.
- **A missing or incompatible build fails startup.** An absent, unreadable, or empty file, or a page
without exactly one empty API-origin element, stops `startApi()` with a fixed
`DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
already opened. It never carries a path or the filesystem error. Build the shell first;
`pnpm api:start` already runs the full build.
- **Two exact routes, never a directory.** `GET /` answers the page and
`GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and
nothing touches the filesystem while answering. Every other path, including `/index.html`,
`/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary
`404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client
router, and emailed links land on `/` with a query (see
[The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's
implicit `HEAD`) are also the ordinary `404`.
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two
files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
a `404` for it.
Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's
CORS policy does not apply. The bytes grant no authority: the page renders sign-in until
`/api/v1/principal` says otherwise.
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` |
| --- | --- | --- |
| `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` |
| `Cache-Control` | `no-cache` | `no-cache` |
| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script |
| `X-Content-Type-Options` | `nosniff` | `nosniff` |
| `X-Frame-Options` | `DENY` | — |
| `Content-Security-Policy` | `frame-ancestors 'none'` | — |
| `Referrer-Policy` | `no-referrer` | — |
**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every
load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match`
(strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and
`Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry
keeps them. Neither file is ever `immutable`: a rebuilt Dashboard reaches a browser on its next load.
**Page security headers.** The page holds the platform sign-in form, so it refuses to be framed,
through `X-Frame-Options` and CSP `frame-ancestors`. That is the only directive the policy carries:
the full Content-Security-Policy is a separate decision. `no-referrer` keeps the page URL out of
every request the page makes, which matters while an emailed reset token is briefly in it. The shell
strips that token from the address bar before it renders anything.
**Development.** `pnpm --filter @ekklesia/shell dev` serves `dist/browser` at the same absolute
paths, because the build emits the script at `assets/ekklesia-dashboard/app.js`. `index.html` names
that path absolutely, so the page never depends on the URL that served it. For a split-origin
development API, set `EKKLESIA_API_BASE_URL` for the development server: it writes that origin into
its copy of the page exactly as the API does in production. A production build ignores it.
## Dashboard browser authentication
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
@@ -277,7 +352,10 @@ real server: 7 characters rejected, 8 accepted, 128 accepted, 129 rejected.
## Origin, CORS, and cookies
The API reuses the same `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` list for both CORS and Better Auth's
own origin/CSRF checks — there is no second, competing configuration. CORS reflects a request's
own origin/CSRF checks — there is no second, competing configuration. Like Better Auth, the API also
trusts the identity base URL's own origin without it being listed, so a page the API itself serves
(the display host, or the Dashboard in the same-origin topology) passes the API's origin checks
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). CORS reflects a request's
`Origin` header back only when it exactly matches a configured trusted origin (never a wildcard,
since `Access-Control-Allow-Origin: *` cannot be combined with `Access-Control-Allow-Credentials:
true`); a disallowed origin never receives the header at all, though the request itself may still
@@ -311,10 +389,18 @@ Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
CORS/CSRF above. The Dashboard does not broaden this policy: same-origin `/api` proxying is the
simplest production topology, while a deliberately split browser/API origin must use the exact
trusted-origin allowlist and credentialed CORS. Sibling subdomains do not implicitly share the
cookie. No production Dashboard hostname or proxy deployment exists in this repository.
CORS/CSRF above. The Dashboard does not broaden this policy. In the split topology
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the cookie stays
host-only on the API's hostname, and the Dashboard on a sibling hostname reaches the API with
credentialed CORS; browsers send the cookie because the two hostnames are origins of one site, which
is therefore a requirement of that topology. The reference deployment provides both topologies
([`deploy/README.md`](../../deploy/README.md#split-dashboard-and-api-origins)).
**CORS request and response headers are deliberately narrow, and part of the module UI contract.**
Beyond the CORS-safelisted request headers, only `Content-Type` is allowed, and no response header is
exposed beyond the safelisted ones. A module UI's `api.request` inherits this policy, so portable
module UI code sends only safelisted headers plus `Content-Type` and reads only safelisted response
headers. That keeps one module artifact working in either topology.
## Logging
@@ -1420,6 +1506,51 @@ TLS protects the *connection*, not the exposure: it does not make an internet-re
safe, and the network boundary remains the deployment's own responsibility. See
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
## Health
Two unauthenticated endpoints exist for Compose health checks and external monitoring
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). They are
deliberately uninformative: neither names a module, version, dependency, error, or connection
detail, and `pnpm doctor` remains the detailed operator report.
| Request | Answer |
| --- | --- |
| `GET /api/v1/health/live` | Always `200 {"status":"live"}`. Liveness consults nothing; if the process can answer, it is live. |
| `GET /api/v1/health/ready`, ready | `200 {"status":"ready"}` |
| `GET /api/v1/health/ready`, not ready | `503 {"code":"NOT_READY"}` |
| Any other method on either path, including `HEAD` | `405` with `Allow: GET` and no body |
Every answer sets `Cache-Control: no-store`. Neither route resolves a principal, reads a cookie, or
evaluates authorization. Both live under `/api/v1/health/`, a sibling of the `/api/v1/modules/`
prefix, so no module route can shadow them, including a module whose id is `health`. As for every
`/api/v1/*` route, an `OPTIONS` request is answered by the platform's CORS preflight and never
reaches the handlers.
Readiness is `200` only when **both** of the following hold:
- **Module startup completed, and shutdown has not begun.** `startApi()` binds the HTTP listener
only after the module runtime has started, so this holds whenever the server is answering. The
first `shutdown()` call clears it before anything drains, so a draining instance reports `503`
while liveness still reports `200`. It is checked again after the database ping, in case shutdown
began meanwhile.
- **The database answered a trivial query within 2 seconds** (`READINESS_DATABASE_TIMEOUT_MS`).
The ping runs on the one shared pool as a single message,
`BEGIN READ ONLY; SET LOCAL statement_timeout = 2000; SELECT 1; COMMIT`. The transaction-local
timeout means PostgreSQL cancels a stalled statement itself rather than leaving it running. On
any failure the pool discards that connection instead of reusing it. The pool's own 10-second
connection, query, and statement timeouts remain the outer bound, for example for a database
that cannot be reached at all.
**Pings never pile up.** At most one ping is ever unsettled. Probes that arrive while it runs share
its result and its single 2-second deadline. Once the deadline passes they answer `503` immediately,
without starting another query, until that ping has actually settled. A stalled database therefore
holds at most one pool connection on behalf of health checks, however often a monitor polls. A
successful result is never cached: every probe after a settled ping runs a new one.
A failed ping is never inspected. The response is the same fixed `503` whether the database
refused the connection, timed out, rejected the credentials, or was mid-restart, and nothing about
the failure is logged by the route.
## Graceful shutdown
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
@@ -1504,7 +1635,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
See the repository root [README](../../README.md#current-status) and
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
for what has landed across every phase and what remains. Surface work is underway; production
Dashboard asset hosting, enrollment/recovery, display device identity, and public Website routing
remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
for what has landed across every phase and what remains. Surface work is underway; the API now
serves the built Dashboard ([Dashboard host](#dashboard-host)) in either topology, while the full
Content-Security-Policy, enrollment/recovery, display device identity, and public Website
routing remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
database-composition contracts as they evolve.
+70 -30
View File
@@ -153,7 +153,8 @@ enforced by `requirePermission` regardless; the test suite proves it by having a
"denied" call its API anyway and still receive `403`.
**The host executes trusted module code in its own page and does not pretend otherwise.** The loader
performs a same-origin dynamic `import()` — no `eval`, no `new Function`, no `blob:` URL — and the
performs a dynamic `import()` from the API's origin (the page's own in the same-origin topology, the
API hostname in the split one, ADR 0045) — no `eval`, no `new Function`, no `blob:` URL — and the
shell owns the container, the loading state, and every error, so a failing module costs its own panel
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
JavaScript, exactly as described above.
@@ -463,10 +464,13 @@ Until that exists, the trusted-code model above is the security model.
## Current deployment state
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no reverse
proxy, no public listener, and no deployment automation of any kind in this repository — no deploy
script, no CI deployment step, no container publish, no orchestration manifest. No `.env` has ever
been committed; only `.env.example`.
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no running
reverse proxy, no public listener, and no deployment automation in this repository — no deploy
script, no CI deployment step, no container publish. The repository provides a **reference deployment**
([`deploy/`](../../deploy/README.md)): a production image and the Compose project that runs it, which
CI builds and smoke-tests end to end on every change against disposable services. Nothing pushes,
publishes, or runs it against a real installation. No `.env` has ever been committed; only
`.env.example`, which the image build context excludes.
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
@@ -486,9 +490,13 @@ This checklist gates a **public launch**. It does not gate development: Surfaces
work all proceed against the local development infrastructure while every item below is still
outstanding.
1. The reverse proxy serves the built Dashboard assets and `/api` on the intended origin, or an
explicitly split origin is covered by the exact trusted-origin/CORS configuration. The
repository currently supplies neither deployment.
1. The reverse proxy forwards the Dashboard and `/api` on the intended origin, or an explicitly
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)), and the
reference deployment's proxy routes it ([deploy/README.md](../../deploy/README.md#launch-checklist)
maps every item here to its configuration). A split installation (ADR 0045) puts its Dashboard
and API hostnames on one registrable domain and uses `Caddyfile.split.example`, which refuses
`/api/*` on the Dashboard's hostname so the session cookie has exactly one home.
2. The origin port is unreachable from the public Internet.
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
@@ -685,8 +693,10 @@ public Dashboard login must be assessed with the actual edge/proxy controls befo
## Continuous integration boundary
CI runs on self-hosted Gitea Actions runners. The inventoried server is **Gitea 1.24.2**, and that
version determines which parts of the workflow are real guarantees and which are not.
CI runs on self-hosted Gitea Actions runners. The server reports **Gitea 1.27.2** (`GET
/api/v1/version`, checked 2026-10-06; this document previously said 1.24.2, which was stale). The
server's actual version, not the documentation of an earlier one, determines which parts of the
workflow are real guarantees and which are not.
### The current operating rule: trusted code only
@@ -719,24 +729,53 @@ so a later step in the same job cannot read or reuse it from there.
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
reduce that token's server-side authority. A step that wants the token can still read it.
### What Gitea 1.24 ignores — so the branch cannot claim it
### What this server honours, and what is not yet verified
Per the official comparison for this version
(<https://docs.gitea.com/1.24/usage/actions/comparison>), Gitea 1.24's unsupported workflow syntax
includes `permissions` and `jobs.<job_id>.permissions`, `concurrency`, and
`jobs.<job_id>.timeout-minutes`. On this server those declarations are inert. Therefore:
Each item separates what Gitea documents for 1.27
(<https://docs.gitea.com/1.27/usage/actions/comparison>) from what has been observed on this server:
- **The workflow's `permissions: contents: read` does not restrict the job token.** This branch
cannot claim the `GITEA_TOKEN` is read-only, because nothing in workflow YAML makes it so on
1.24. Job-token permission controls, repository/owner defaults, and server-side
maximum-permission clamping arrive in the 1.26 feature line
(<https://docs.gitea.com/1.26/usage/actions/token-permissions>).
- **`concurrency` and `timeout-minutes` are not bounded-execution guarantees here.** Cancellation,
timeout, and teardown must be enforced by a trusted provisioner and reaper outside the workflow.
- **`concurrency` is honoured.** Gitea supports it since 1.26 (go-gitea/gitea#32751), and on this
server a newer push to a pull request cancels the run it supersedes. Observed: Ekklesia run 133 was
cancelled when run 134 started for the same pull request, and run 135 when run 137 did. A
superseded run's own cleanup is therefore never guaranteed to run, which is one reason for the
stack sweep below.
- **`permissions` is supported by 1.27, with Gitea's own scopes, but is not verified here.** The
server-side token-permission settings are not visible through the API, and nothing has yet proven
what the job token can and cannot do on this server. Until that proof exists (requirement 3
below), this repository still does not claim the `GITEA_TOKEN` is read-only.
- **`timeout-minutes` is no longer listed as unsupported for 1.27, but is not verified here.**
Teardown does not rely on it: the stack sweep and a trusted reaper do.
- **Still ignored:** `jobs.<job_id>.environment`, and any `runs-on` other than a single label.
The declarations are kept deliberately: they are already correct for a supported Gitea release and
for GitHub Actions, and keeping them means the upgrade does not require re-deriving the intent. They
are documented as ignored, not removed.
The declarations stay in the workflow either way: they state the intent, and are correct on GitHub
Actions as well.
### Disposable stacks never outlive their job
Every CI script removes its own Compose stack in an `EXIT` trap, but a trap never runs when the runner
kills the job container: a cancelled job, or the runner's own time limit. One such stack ran on the
shared runner for two days. So stack cleanup has three layers:
- **The script's own trap**, for every ordinary failure.
- **A final `if: always()` step** in every job that starts stacks: `scripts/ci-sweep.sh --job` removes
everything that job started, at any age, even after a failed step.
- **A sweep before every such job starts**: `scripts/ci-sweep.sh --stale` removes anything a CI job
left more than three hours ago, which catches whatever a killed job could not clean up itself.
A stack is found by name and label, never by guesswork (`scripts/lib/ci-project.sh`):
- every CI Compose project is named `ekklesia-ci-<run>-<attempt>-<job key>-…`, and Compose labels
its containers, networks, and volumes with that name;
- anything a script starts outside Compose carries `org.riverofglorymn.ekklesia.ci.project`;
- images also carry the time CI built them, because an image's own creation time can be its base's.
The job key is per matrix leg, so one leg's cleanup cannot match another's running stack. Nothing
outside the `ekklesia-ci-` prefix is ever removed. A job container attached to a stack's network is
disconnected, never removed.
**This is not a substitute for a runner-side reaper.** A stack killed with its job is removed only
when another job that sweeps runs on the same runner. Bounded execution and teardown are still the
runner host's to enforce, as above.
### Runner topology is not verified
@@ -758,16 +797,17 @@ they are not conditions on merging trusted, reviewed work.
None of them can be satisfied by editing this repository; each requires access to the Gitea server
and runner hosts, and each must be proven there.
1. Upgrade Gitea to a reviewed supported release with job-token permission controls — at least the
1.26 feature line. Select the actual release during infrastructure work, after backup and
compatibility review; do not pin a version from this document.
1. ~~Upgrade Gitea to a reviewed supported release with job-token permission controls — at least
the 1.26 feature line.~~ **Done:** the server runs 1.27.2. Items 2 and 3 now configure and prove
what it makes possible.
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
code cannot request more than read-only repository access.
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
releases, packages, issues, or pull requests, change Actions configuration, or reach another
private repository.
4. Enforce job timeout, cancellation, concurrency, and teardown in the trusted
provisioner/reaper rather than relying on the ignored workflow keys above.
4. Enforce job timeout and teardown in the trusted provisioner/reaper. `concurrency` is honoured
by the server; `timeout-minutes` is not yet verified, and the in-workflow stack sweep only
complements a reaper.
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
reusable key.
+6
View File
@@ -159,6 +159,12 @@ also receives a safely named disposable database. Direct host runs use a Docker-
port bound only to `127.0.0.1`; a containerized Gitea job joins the isolated Compose network and
uses the PostgreSQL service name.
In CI, a stack whose job was killed before its own cleanup ran is still removed: by that job's final
`if: always()` step, or by the stale sweep before a later job starts (`scripts/ci-sweep.sh`, see
[Disposable stacks never outlive their job](security.md#disposable-stacks-never-outlive-their-job)).
`bash scripts/test-ci-sweep.sh` proves what the sweep removes and what it leaves alone, against real
Docker resources.
The live suite proves fresh read-only planning, initial and incremental application, genuine
no-op behavior, exact applied checksums and history, edited or corrupt history rejection,
whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concurrency and
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ekklesia",
"version": "0.1.0",
"version": "0.1.2",
"private": true,
"type": "module",
"description": "Open, modular church technology platform",
@@ -14,7 +14,9 @@
"authorization:bootstrap-owner": "turbo run build && node --env-file-if-exists=.env core/dist/cli/bootstrap-platform-owner.js",
"boundaries": "node scripts/check-boundaries.ts",
"build": "turbo run build",
"ci:verify": "pnpm verify && pnpm infrastructure:config",
"ci:verify": "pnpm verify && pnpm infrastructure:config && pnpm deploy:config",
"deploy:config": "bash scripts/check-deploy-config.sh",
"deploy:smoke": "bash scripts/smoke-deploy.sh",
"doctor": "node scripts/run-doctor.ts",
"format": "biome format --write .",
"format:check": "biome format .",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/contracts",
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/design-tokens",
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"files": [
"dist"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-sdk",
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"files": [
"dist"
@@ -19,12 +19,12 @@
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*"
"@ekklesia/contracts": "workspace:*",
"drizzle-orm": "0.45.2"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"drizzle-orm": "0.45.2",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-ui-build",
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"files": [
"bin",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-ui-host",
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-ui-sdk",
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"files": [
"dist"
@@ -125,6 +125,11 @@ export interface ModuleUiDisplayLocation {
*
* A host is responsible for credentials, base URL, and any platform headers. A module UI neither
* supplies nor sees a session token.
*
* The module's API may be on another origin than the page (Ekklesia's split topology, ADR 0045), so
* a portable module UI keeps to what cross-origin requests always allow: CORS-safelisted request
* headers plus `Content-Type`, and only CORS-safelisted response headers read back. Anything more
* works under one host topology and fails under another.
*/
export interface ModuleUiApiClient {
/**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/typescript-config",
"version": "0.1.0",
"version": "0.1.2",
"private": true,
"files": [
"base.json",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/ui",
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"files": [
"dist"
@@ -19,7 +19,8 @@
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
},
"dependencies": {
"@ekklesia/design-tokens": "workspace:*"
"@ekklesia/design-tokens": "workspace:*",
"react": "19.2.8"
},
"peerDependencies": {
"react": "19.2.8"
@@ -31,7 +32,6 @@
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"happy-dom": "20.0.11",
"react": "19.2.8",
"react-dom": "19.2.8",
"typescript": "7.0.2",
"vitest": "4.1.10"
+5 -1
View File
@@ -67,7 +67,11 @@ describe("the shared elements consume design tokens, never hardcoded theme value
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens"]);
// React is both a production dependency and a peer: the peer states that the consumer's React
// must be the same one, and the production declaration is what makes the package's own imports
// resolve, since a consumer never satisfies a linked workspace package's peer
// (`runtime-imports-are-production-dependencies`).
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens", "react"]);
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual(["react"]);
});
+6 -6
View File
@@ -201,6 +201,9 @@ importers:
'@ekklesia/contracts':
specifier: workspace:*
version: link:../contracts
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected])
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
@@ -208,9 +211,6 @@ importers:
'@types/node':
specifier: 24.13.3
version: 24.13.3
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected])
typescript:
specifier: 7.0.2
version: 7.0.2
@@ -287,6 +287,9 @@ importers:
'@ekklesia/design-tokens':
specifier: workspace:*
version: link:../design-tokens
react:
specifier: 19.2.8
version: 19.2.8
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
@@ -306,9 +309,6 @@ importers:
happy-dom:
specifier: 20.0.11
version: 20.0.11
react:
specifier: 19.2.8
version: 19.2.8
react-dom:
specifier: 19.2.8
version: 19.2.8([email protected])
+120
View File
@@ -1,4 +1,5 @@
import { readdir, readFile, stat } from "node:fs/promises";
import { builtinModules } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parse } from "yaml";
@@ -506,6 +507,82 @@ function collectImportSpecifiers(sourceText: string): string[] {
return [...specifiers];
}
/**
* Whether a file is code a workspace package ships and executes: anything under its `src/` or
* `bin/`, except tests and declaration files. Build scripts, configuration files, and tests run with
* development dependencies installed, so they are outside this rule.
*/
function isRuntimeSourceFile(packageRelativePath: string): boolean {
const normalized = normalizePath(packageRelativePath);
if (!normalized.startsWith("src/") && !normalized.startsWith("bin/")) {
return false;
}
const segments = normalized.split("/");
if (segments.some((segment) => ["__tests__", "test", "tests"].includes(segment))) {
return false;
}
const fileName = segments.at(-1) ?? "";
return !/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(fileName) && !/\.d\.[cm]?ts$/u.test(fileName);
}
/** `import { type A, type B } from "x"` has no runtime binding: without `verbatimModuleSyntax`, the
* compiler erases it exactly like `import type`. A default or namespace binding, or any specifier
* without `type`, is a runtime import. */
function importClauseIsTypeOnly(clause: string): boolean {
const trimmed = clause.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return false;
}
const specifiers = trimmed
.slice(1, -1)
.split(",")
.map((specifier) => specifier.trim())
.filter((specifier) => specifier.length > 0);
return specifiers.length > 0 && specifiers.every((specifier) => /^type\s/u.test(specifier));
}
/**
* Bare specifiers a source file needs at run time: every static, side-effect, and dynamic import
* except type-only ones. A `.tsx`/`.jsx` file also needs `react`, because the platform compiles JSX
* with the automatic runtime (`"jsx": "react-jsx"`), which imports `react/jsx-runtime` without that
* import appearing in the source.
*/
function collectRuntimeImportSpecifiers(sourceText: string, extension: string): string[] {
const specifiers = new Set<string>();
const staticImport = /\b(?:import|export)\s+(type\s+)?([^;]*?)\bfrom\s*["']([^"'\n]+)["']/gu;
for (const match of sourceText.matchAll(staticImport)) {
const [, typeKeyword, clause = "", specifier] = match;
if (specifier && !typeKeyword && !importClauseIsTypeOnly(clause)) {
specifiers.add(specifier);
}
}
const sideEffectImport = /\bimport\s*["']([^"'\n]+)["']/gu;
const dynamicImport = /\b(?:import|require)\s*\(\s*["']([^"'\n]+)["']\s*\)/gu;
for (const pattern of [sideEffectImport, dynamicImport]) {
for (const match of sourceText.matchAll(pattern)) {
if (match[1]) {
specifiers.add(match[1]);
}
}
}
if (extension === ".tsx" || extension === ".jsx") {
specifiers.add("react/jsx-runtime");
}
return [...specifiers];
}
const NODE_BUILTIN_MODULES = new Set(builtinModules);
function isBareSpecifier(specifier: string): boolean {
return !(
specifier.startsWith(".") ||
specifier.startsWith("/") ||
specifier.startsWith("#") ||
specifier.startsWith("node:") ||
/^[a-z][a-z0-9+.-]*:/u.test(specifier)
);
}
async function resolveSourceImport(
fromFile: string,
specifier: string,
@@ -906,6 +983,49 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
sourceGraph.set(relativeFile, new Set());
const sourceText = await readFile(codeFile, "utf8");
// A workspace package is linked into its consumers as a directory, so Node resolves the
// package's own imports from the package's own `node_modules`. A peer dependency is never
// satisfied there by the consumer, and a production install (`pnpm install --prod`, as the
// production image runs) omits development dependencies. Code a package ships must therefore
// declare every runtime import as a production dependency. A peer declaration may stay beside it.
const runtimePackage = findContainingPackage(codeFile, packagesByDepth);
if (runtimePackage && isRuntimeSourceFile(path.relative(runtimePackage.directory, codeFile))) {
const reported = new Set<string>();
for (const specifier of collectRuntimeImportSpecifiers(
sourceText,
path.extname(codeFile).toLowerCase(),
)) {
if (!isBareSpecifier(specifier)) {
continue;
}
const dependencyName = packageNameFromSpecifier(specifier);
const manifest = runtimePackage.manifest;
if (
reported.has(dependencyName) ||
dependencyName === runtimePackage.name ||
NODE_BUILTIN_MODULES.has(dependencyName) ||
manifest.dependencies?.[dependencyName] !== undefined ||
manifest.optionalDependencies?.[dependencyName] !== undefined
) {
continue;
}
reported.add(dependencyName);
const declaredAs =
manifest.peerDependencies?.[dependencyName] !== undefined
? "only as a peer dependency"
: manifest.devDependencies?.[dependencyName] !== undefined
? "only as a development dependency"
: "not at all";
violations.push({
from: relativeFile,
message: `${runtimePackage.name} imports ${dependencyName} at run time but declares it ${declaredAs}; a runtime import must be in dependencies.`,
rule: "runtime-imports-are-production-dependencies",
to: dependencyName,
});
}
}
for (const specifier of collectImportSpecifiers(sourceText)) {
const dependencyName = packageNameFromSpecifier(specifier);
if (from.layer === "module" && isModuleUiPackage(dependencyName)) {
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Validates the reference deployment's Compose files (ADR 0044) in each supported combination:
# base, base + proxy, and base + proxy + tunnel. Uses only the committed example env files, copied
# to a temporary directory, so it needs no secret and starts nothing.
set -Eeuo pipefail
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repository_root/deploy"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
for name in ekklesia postgres object-storage tunnel; do
cp "env/${name}.env.example" "$work/${name}.env"
done
cp env/compose.env.example "$work/compose.env"
cat >>"$work/compose.env" <<EOF_ENV
EKKLESIA_IMAGE=registry.example.org/ekklesia:0.0.0
EKKLESIA_ENV_DIR=${work}
EKKLESIA_HOSTNAME=ekklesia.example.org
EKKLESIA_STORAGE_HOSTNAME=storage.ekklesia.example.org
EKKLESIA_ACME_EMAIL=[email protected]
EOF_ENV
combinations=(
"compose.yaml"
"compose.yaml compose.proxy.yaml"
"compose.yaml compose.proxy.yaml compose.tunnel.yaml"
)
for combination in "${combinations[@]}"; do
arguments=()
for file in $combination; do
arguments+=(--file "$file")
done
if ! docker compose --env-file "$work/compose.env" "${arguments[@]}" --profile ops config --quiet; then
echo "Reference deployment Compose configuration is invalid: ${combination}" >&2
exit 1
fi
echo "valid: ${combination}"
done
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
# Removes disposable CI stacks from the Docker daemon: containers, networks, volumes, images, and
# staging directories on the Docker host.
#
# bash scripts/ci-sweep.sh --stale [--max-age-seconds N] any CI stack older than N (default 3 h)
# bash scripts/ci-sweep.sh --job this job's own stacks, at any age
#
# Every CI script already removes its own stack in an EXIT trap, but a trap never runs when the runner
# kills the job container: a cancelled or superseded job (the server honours `concurrency`), or
# the runner's own time limit. So each workflow job that starts stacks ends with `--job` in an
# `if: always()` step, and begins with `--stale`, which catches whatever a killed job left before
# it could clean up.
#
# What a stack is, and how it is found, is defined in scripts/lib/ci-project.sh: a Compose project
# named with the `ekklesia-ci-` prefix (Compose labels everything it creates with that name), plus
# anything labeled EKKLESIA_CI_LABEL. Nothing outside that prefix is ever touched, at any age.
#
# --stale's threshold is far above any job's run time, so a stack another job on this daemon is using
# right now is never young enough to match. --job refuses to run outside CI: locally, its prefix would
# match a developer's own stacks.
#
# Best effort by design: a resource that cannot be removed is reported, and the sweep goes on. Output
# names resources only; nothing a stack holds is printed.
set -Eeuo pipefail
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=scripts/lib/ci-project.sh
source "$repository_root/scripts/lib/ci-project.sh"
compose_label="com.docker.compose.project"
mode=""
max_age_seconds="${EKKLESIA_CI_SWEEP_MAX_AGE_SECONDS:-10800}"
usage() {
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] | --job" >&2
exit 2
}
while (($# > 0)); do
case "$1" in
--stale | --job)
[[ -z "$mode" ]] || usage
mode="${1#--}"
;;
--max-age-seconds)
[[ "${2:-}" =~ ^[0-9]+$ ]] || usage
max_age_seconds="$2"
shift
;;
*) usage ;;
esac
shift
done
[[ -n "$mode" ]] || usage
if [[ "$mode" == job ]]; then
[[ -n "${GITHUB_RUN_ID:-}" ]] || {
echo "ci-sweep: --job runs only in CI (GITHUB_RUN_ID is unset); locally it would match your own stacks." >&2
exit 2
}
prefix="$(ci_job_prefix)"
cutoff=""
else
prefix="$EKKLESIA_CI_PREFIX"
cutoff="$(($(date +%s) - max_age_seconds))"
fi
if ! docker info >/dev/null 2>&1; then
echo "ci-sweep: no Docker daemon reachable; nothing to sweep."
exit 0
fi
removed=0
warn() {
echo "ci-sweep: warning: $*" >&2
}
# selected <project> <created>: whether a resource belongs to the sweep.
selected() {
local project="$1" created="$2" seconds
[[ -n "$project" && "$project" == "$prefix"* ]] || return 1
[[ -z "$cutoff" ]] && return 0
seconds="$(date -d "$created" +%s 2>/dev/null)" || return 1
((seconds <= cutoff))
}
# --- Containers: Compose's own, and those a script started itself --------------------------------
mapfile -t containers < <(
{
docker ps --all --quiet --no-trunc --filter "label=${compose_label}"
docker ps --all --quiet --no-trunc --filter "label=${EKKLESIA_CI_LABEL}"
} | sort -u
)
for id in "${containers[@]}"; do
[[ -n "$id" ]] || continue
record="$(docker inspect --format \
"{{index .Config.Labels \"${compose_label}\"}}|{{index .Config.Labels \"${EKKLESIA_CI_LABEL}\"}}|{{.Created}}|{{.Name}}" \
"$id" 2>/dev/null)" || continue
IFS='|' read -r compose_project ci_project created name <<<"$record"
project="${compose_project:-$ci_project}"
selected "$project" "$created" || continue
if docker rm --force --volumes "$id" >/dev/null 2>&1; then
echo "ci-sweep: removed container ${name#/} (${project})"
removed=$((removed + 1))
else
warn "could not remove container ${name#/}"
fi
done
# --- Networks: anything still attached (a job container) is disconnected, never removed ---------
mapfile -t networks < <(docker network ls --quiet --no-trunc --filter "label=${compose_label}")
for id in "${networks[@]}"; do
[[ -n "$id" ]] || continue
# A network's creation time is a time value, not a string: ask for RFC 3339, which `date` reads.
record="$(docker network inspect --format \
"{{index .Labels \"${compose_label}\"}}|{{.Created.Format \"2006-01-02T15:04:05Z07:00\"}}|{{.Name}}" \
"$id" 2>/dev/null)" || continue
IFS='|' read -r project created name <<<"$record"
selected "$project" "$created" || continue
for endpoint in $(docker network inspect --format '{{range $id, $_ := .Containers}}{{$id}} {{end}}' "$id" 2>/dev/null); do
docker network disconnect --force "$id" "$endpoint" >/dev/null 2>&1 ||
warn "could not disconnect a container from network ${name}"
done
if docker network rm "$id" >/dev/null 2>&1; then
echo "ci-sweep: removed network ${name} (${project})"
removed=$((removed + 1))
else
warn "could not remove network ${name}"
fi
done
# --- Volumes ---------------------------------------------------------------------------------------
mapfile -t volumes < <(
{
docker volume ls --quiet --filter "label=${compose_label}"
docker volume ls --quiet --filter "label=${EKKLESIA_CI_LABEL}"
} | sort -u
)
for name in "${volumes[@]}"; do
[[ -n "$name" ]] || continue
record="$(docker volume inspect --format \
"{{index .Labels \"${compose_label}\"}}|{{index .Labels \"${EKKLESIA_CI_LABEL}\"}}|{{.CreatedAt}}" \
"$name" 2>/dev/null)" || continue
IFS='|' read -r compose_project ci_project created <<<"$record"
project="${compose_project:-$ci_project}"
selected "$project" "$created" || continue
if docker volume rm --force "$name" >/dev/null 2>&1; then
echo "ci-sweep: removed volume ${name} (${project})"
removed=$((removed + 1))
else
warn "could not remove volume ${name}"
fi
done
# --- Images built for a stack ------------------------------------------------------------------------
mapfile -t images < <(docker image ls --quiet --no-trunc --filter "label=${EKKLESIA_CI_LABEL}" | sort -u)
for id in "${images[@]}"; do
[[ -n "$id" ]] || continue
# Judged by when CI built it (EKKLESIA_CI_CREATED_LABEL), never by the image's own creation time,
# which BuildKit may have taken from its base. An image without that label is never swept by age.
record="$(docker image inspect --format \
"{{index .Config.Labels \"${EKKLESIA_CI_LABEL}\"}}|{{index .Config.Labels \"${EKKLESIA_CI_CREATED_LABEL}\"}}" \
"$id" 2>/dev/null)" || continue
IFS='|' read -r project built <<<"$record"
if [[ -n "$cutoff" ]]; then
[[ "$built" =~ ^[0-9]+$ ]] || continue
selected "$project" "@${built}" || continue
else
selected "$project" "" || continue
fi
if docker image rm --force "$id" >/dev/null 2>&1; then
echo "ci-sweep: removed image ${id#sha256:} (${project})"
removed=$((removed + 1))
else
warn "could not remove image ${id#sha256:}"
fi
done
# --- Staging directories on the Docker host ------------------------------------------------------
# A deploy smoke run inside a job container stages its mounted files under the Docker host's /tmp,
# named for its project, as root-owned files; they hold that run's disposable secrets and keys. They
# are removed with the same pinned image that created them. If that image is not on this daemon, no
# smoke has staged anything here.
image="$(sed -n 's/^[[:space:]]*image: \(postgres:[^[:space:]]*\)$/\1/p' "$repository_root/deploy/compose.yaml" | head -1)"
if [[ -n "$image" ]] && docker image inspect "$image" >/dev/null 2>&1; then
age=()
# `find -mmin +0` does not match something younger than a minute, so a threshold under a minute
# sweeps without an age filter, like the rest of this script.
if [[ -n "$cutoff" ]] && ((max_age_seconds >= 60)); then
age=(-mmin "+$((max_age_seconds / 60))")
fi
if staged="$(docker run --rm --entrypoint find -v /tmp:/host-tmp "$image" \
/host-tmp -mindepth 1 -maxdepth 1 -type d -name "${prefix}*" "${age[@]}" \
-print -exec rm -rf {} + 2>/dev/null)"; then
while IFS= read -r directory; do
[[ -n "$directory" ]] || continue
echo "ci-sweep: removed staging directory /tmp/${directory#/host-tmp/}"
removed=$((removed + 1))
done <<<"$staged"
else
warn "could not sweep staging directories on the Docker host"
fi
fi
echo "ci-sweep: ${mode} sweep removed ${removed} resource(s)."
+40
View File
@@ -0,0 +1,40 @@
# shellcheck shell=bash
# How CI names, labels, and later finds the disposable stacks it starts.
#
# Every Compose project a CI script starts is named
#
# ekklesia-ci-<run id>-<run attempt>-<job key>-<kind>-<pid><random>
#
# and Compose labels every container, network, and volume it creates with that name. What a script
# creates outside Compose (a browser container, a volume, an image) gets EKKLESIA_CI_LABEL with the
# same name. scripts/ci-sweep.sh finds stacks by those labels: this job's own, by the prefix up to
# the job key (in a final `if: always()` step), and any CI stack by age (before a job starts).
#
# The job key is EKKLESIA_CI_JOB_KEY, which the workflow sets per job, and per matrix leg where a job
# has one, so that one leg's cleanup cannot match another leg's running stack. Outside CI the
# identifiers are "local".
# shellcheck disable=SC2034 # read by the scripts that source this
EKKLESIA_CI_LABEL="org.riverofglorymn.ekklesia.ci.project"
# On images, when they were built, in Unix seconds. An image's own creation time is not that: BuildKit
# gives an image that only adds metadata its base's time, and a reproducible build a fixed one. A
# stale sweep that trusted it could remove an image another job is using right now.
EKKLESIA_CI_CREATED_LABEL="org.riverofglorymn.ekklesia.ci.created"
EKKLESIA_CI_PREFIX="ekklesia-ci-"
# ci_name_safe <text>: lower case, and only what a Compose project name may contain.
ci_name_safe() {
local text="${1//[^a-zA-Z0-9_-]/-}"
printf '%s' "${text,,}"
}
# ci_job_prefix: every project this job starts begins with exactly this.
ci_job_prefix() {
ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${EKKLESIA_CI_JOB_KEY:-${GITHUB_JOB:-local}}-"
}
# ci_project_name <kind>: a project name unique to this invocation. Not only per run: several runs,
# and several jobs of one run, share a runner's Docker daemon, and job containers often share a PID.
ci_project_name() {
ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${EKKLESIA_CI_JOB_KEY:-${GITHUB_JOB:-local}}-${1:?ci_project_name needs a kind}-$$-${RANDOM}${RANDOM}"
}
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Why a disposable Compose project did not become ready: `docker compose ps` and the last log lines
# of every service, so a CI failure says what went wrong (a port already allocated, a container that
# exited, a health check that never passed) instead of only that something did.
#
# source scripts/lib/compose-diagnostics.sh
# COMPOSE_DIAGNOSTICS_SECRETS=("$password" "$secret_key") # literal values to mask, optional
# compose_report_not_ready "$up_output" "${compose[@]}"
#
# Everything printed passes through `compose_diagnostics_redact` first. The projects these scripts
# start hold only disposable, generated test credentials, but output is redacted anyway: every
# literal in COMPOSE_DIAGNOSTICS_SECRETS, the value of any `NAME=value` or `NAME: value` whose name
# looks like a credential, the user-information part of any URL, and the rest of any Authorization
# header. Terminal control sequences are stripped. Output goes to standard error.
COMPOSE_DIAGNOSTICS_SECRETS=()
COMPOSE_DIAGNOSTICS_TAIL_LINES=40
# compose_diagnostics_redact: standard input to standard output, with credentials masked.
compose_diagnostics_redact() {
local secrets=""
if ((${#COMPOSE_DIAGNOSTICS_SECRETS[@]} > 0)); then
secrets="$(printf '%s\n' "${COMPOSE_DIAGNOSTICS_SECRETS[@]}")"
fi
# Literal values first, by plain substring rather than as patterns. Very short values are skipped:
# masking every occurrence of a two-letter word would only make the output unreadable.
COMPOSE_DIAGNOSTICS_LITERALS="$secrets" awk '
BEGIN { count = split(ENVIRON["COMPOSE_DIAGNOSTICS_LITERALS"], secret, "\n") }
{
line = $0
for (i = 1; i <= count; i++) {
if (length(secret[i]) < 6) {
continue
}
masked = ""
while ((at = index(line, secret[i])) > 0) {
masked = masked substr(line, 1, at - 1) "[redacted]"
line = substr(line, at + length(secret[i]))
}
line = masked line
}
print line
}
' | sed -E \
-e 's/\x1b\[[0-9;?]*[A-Za-z]//g' \
-e 's#([Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn][[:space:]]*[:=]).*#\1 [redacted]#' \
-e 's#([A-Za-z0-9_.-]*([Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Pp][Aa][Ss][Ss][Ww][Dd]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[_.-][Kk][Ee][Yy])[A-Za-z0-9_.-]*"?[[:space:]]*[=:][[:space:]]*)("[^"]*"|[^[:space:],;"]+)#\1[redacted]#g' \
-e 's#([A-Za-z][A-Za-z0-9+.-]*://)[^/@[:space:]]+@#\1[redacted]@#g'
}
# compose_report_not_ready <up output> <compose command...>: the failed command's own output, then
# the project's containers and the tail of each service's logs, all redacted.
compose_report_not_ready() {
local up_output="$1"
shift
local service services
{
if [[ -n "$up_output" ]]; then
echo "--- docker compose up (last 20 lines)"
printf '%s\n' "$up_output" | tail -n 20 | compose_diagnostics_redact
fi
echo "--- docker compose ps"
"$@" ps --all 2>&1 | compose_diagnostics_redact
services="$("$@" config --services 2>/dev/null || true)"
for service in $services; do
echo "--- docker compose logs ${service} (last ${COMPOSE_DIAGNOSTICS_TAIL_LINES} lines)"
"$@" logs --no-color --tail "$COMPOSE_DIAGNOSTICS_TAIL_LINES" "$service" 2>&1 |
compose_diagnostics_redact
done
echo "--- end of diagnostics"
} >&2
}
+287
View File
@@ -0,0 +1,287 @@
// Real-browser assertions for scripts/smoke-deploy.sh in the split topology (ADR 0045). Runs inside a
// disposable container on the deployment's `edge` network and drives a headless Chromium, itself a
// disposable container on the same network, over the Chrome DevTools Protocol, with Node.js's own
// fetch and WebSocket and no other dependency.
//
// node deploy-smoke-browser.mjs
//
// What only a browser can prove: that a person signing in on the Dashboard's hostname gets a session
// cookie on the API's hostname alone, that the Dashboard then imports a module's UI from the API's
// origin and the module's credentialed requests succeed under CORS, that the module's display page
// mounts on the API's origin with that same session, and that sign-out ends it. Output is one line
// per check; nothing printed carries a credential, cookie value, or response body.
function required(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
}
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
const dashboardOrigin = `https://${required("SMOKE_DASHBOARD_HOSTNAME")}`;
const apiHostname = new URL(apiOrigin).hostname;
const devtools = required("SMOKE_CDP_ADDRESS");
const timeoutMs = 30_000;
let failures = 0;
function check(condition, description) {
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
if (!condition) {
failures += 1;
}
}
/** The smallest Chrome DevTools Protocol client: numbered commands, flat sessions, and events. */
async function connect() {
// The browser container was started moments ago; give it time to listen.
// One overall deadline, with every attempt bounded, so a browser that never comes up fails the
// run in seconds rather than waiting out TCP timeouts.
const deadline = Date.now() + 30_000;
let version;
while (version === undefined) {
try {
const response = await fetch(`http://${devtools}/json/version`, {
signal: AbortSignal.timeout(2_000),
});
version = await response.json();
} catch {
if (Date.now() > deadline) {
throw new Error("The headless browser never answered.");
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
// Chromium names its WebSocket after the Host it was asked on; ask on the same address.
const socketUrl = version.webSocketDebuggerUrl.replace(/^ws:\/\/[^/]+/u, `ws://${devtools}`);
const socket = new WebSocket(socketUrl);
await new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", () => reject(new Error("DevTools connection failed.")), {
once: true,
});
});
let nextId = 1;
const pending = new Map();
const listeners = [];
socket.addEventListener("message", (event) => {
const message = JSON.parse(String(event.data));
if (message.id !== undefined) {
const waiter = pending.get(message.id);
pending.delete(message.id);
if (message.error) {
waiter?.reject(new Error(`DevTools ${waiter.method} failed.`));
} else {
waiter?.resolve(message.result);
}
return;
}
for (const listener of listeners) {
listener(message);
}
});
return {
close: () => socket.close(),
on: (listener) => listeners.push(listener),
send(method, params = {}, sessionId = undefined) {
const id = nextId;
nextId += 1;
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
return new Promise((resolve, reject) => pending.set(id, { method, reject, resolve }));
},
};
}
const cdp = await connect();
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await cdp.send("Target.attachToTarget", { flatten: true, targetId });
const send = (method, params) => cdp.send(method, params, sessionId);
/** Every response and failure the page sees, for the API's origin. A CORS refusal is a
* `loadingFailed` here, never a response. */
const responses = [];
const failed = [];
cdp.on((message) => {
if (message.sessionId !== sessionId) {
return;
}
if (message.method === "Network.responseReceived") {
const { response, type } = message.params;
responses.push({ status: response.status, type, url: response.url });
} else if (message.method === "Network.loadingFailed") {
failed.push(message.params);
}
});
await send("Network.enable");
await send("Page.enable");
await send("Runtime.enable");
// A desktop-sized window: a module may reasonably decline to render its management UI on a small one.
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 1,
height: 1080,
mobile: false,
width: 1920,
});
async function evaluate(expression) {
const result = await send("Runtime.evaluate", {
awaitPromise: true,
expression,
returnByValue: true,
});
if (result.exceptionDetails) {
throw new Error("A page expression threw.");
}
return result.result.value;
}
async function waitFor(expression, description) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await evaluate(`Boolean(${expression})`).catch(() => false)) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
console.log(`FAIL timed out waiting for ${description}`);
failures += 1;
return false;
}
async function navigate(url) {
await send("Page.navigate", { url });
}
const selector = (testId) => `document.querySelector('[data-testid="${testId}"]')`;
async function cookiesFor(origin) {
const { cookies } = await send("Network.getCookies", { urls: [`${origin}/`] });
return cookies;
}
// --- Sign in on the Dashboard's hostname -------------------------------------------------------
await navigate(`${dashboardOrigin}/`);
await waitFor(selector("login-email"), "the sign-in form on the Dashboard hostname");
await evaluate(`(() => {
// React tracks input values through the native setter; assigning .value alone is invisible to it.
const setValue = (element, value) => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(element, value);
element.dispatchEvent(new Event("input", { bubbles: true }));
};
setValue(${selector("login-email")}, ${JSON.stringify(required("SMOKE_OWNER_EMAIL"))});
setValue(${selector("login-password")}, ${JSON.stringify(required("SMOKE_OWNER_PASSWORD"))});
${selector("login-email")}.form.requestSubmit();
})()`);
check(
await waitFor(selector("host-shell"), "the signed-in Dashboard"),
"a person signs in through the real form on the Dashboard hostname",
);
const apiCookies = await cookiesFor(apiOrigin);
const dashboardCookies = await cookiesFor(dashboardOrigin);
check(
apiCookies.some((cookie) => cookie.httpOnly && cookie.secure && cookie.domain === apiHostname) &&
dashboardCookies.length === 0,
"the session cookie is host-only on the API hostname, and the Dashboard hostname holds none",
);
// --- A module UI, imported from the API's origin -------------------------------------------------
const moduleButton = "document.querySelector('[data-testid^=\"select-\"]')";
const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
if (hasModule) {
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
await evaluate(`${moduleButton}.click()`);
const mounted = await waitFor(
`${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
"the module UI to mount",
);
check(
mounted && !(await evaluate(`Boolean(${selector("module-error")})`)),
`the ${moduleId} module UI mounts`,
);
// Give the module's first requests a moment to settle before judging them.
await new Promise((resolve) => setTimeout(resolve, 1_000));
check(
responses.some(
(response) =>
response.url.startsWith(`${apiOrigin}/api/v1/module-ui/${moduleId}/`) &&
response.status === 200,
),
"its UI artifact was imported from the API's origin",
);
check(
responses.some(
(response) =>
response.url.startsWith(`${apiOrigin}/api/v1/modules/${moduleId}`) &&
response.status >= 200 &&
response.status < 300,
),
"its own credentialed API request to the API's origin succeeded under CORS",
);
const streams = responses.filter((response) => response.type === "EventSource");
check(
streams.every((response) => response.status === 200),
`every realtime stream it opened was accepted (${streams.length} opened)`,
);
}
// A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a
// module unmounting, a navigation) is not.
const refused = failed.filter(
(failure) =>
failure.corsErrorStatus !== undefined ||
failure.blockedReason !== undefined ||
!(failure.canceled || failure.errorText === "net::ERR_ABORTED"),
);
check(
refused.length === 0,
`no request from the Dashboard failed or was refused by CORS (${refused.length} refused)`,
);
// --- The display host, on the API's origin, with the same session --------------------------------
const displayPath =
await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/dashboard/navigation`)}, {
credentials: "include",
}).then((response) => response.json()).then((navigation) => {
const entries = [...navigation.ungrouped, ...navigation.groups.flatMap((group) => group.modules)];
return entries.find((entry) => entry.module.display !== null)?.module.display.url ?? null;
})`);
if (displayPath === null) {
console.log("skip no installed module publishes a display");
} else {
await navigate(`${apiOrigin}${displayPath}`);
check(
await waitFor(
`${selector("display-module-container")}?.childElementCount > 0 && !${selector("display-error")}`,
"the display to mount",
),
"the module's display page mounts on the API hostname with the same session",
);
check(
await waitFor(
"navigator.serviceWorker.getRegistrations().then((registrations) => registrations.length > 0)",
"the display's service worker",
),
"the display's offline service worker registers on the API's origin",
);
}
// --- Sign out on the Dashboard -------------------------------------------------------------------
await navigate(`${dashboardOrigin}/`);
await waitFor(selector("host-logout"), "the sign-out control");
await evaluate(`${selector("host-logout")}.click()`);
check(
await waitFor(selector("login-email"), "the sign-in form after sign-out"),
"sign-out returns the Dashboard to the sign-in form",
);
const principal = await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/principal`)}, {
credentials: "include",
}).then((response) => response.json()).then((body) => body.kind)`);
check(principal === "anonymous", "after sign-out, the API answers anonymous to the same browser");
await cdp.send("Target.closeTarget", { targetId });
cdp.close();
if (failures > 0) {
console.error(`${failures} check(s) failed`);
process.exit(1);
}
+459
View File
@@ -0,0 +1,459 @@
// HTTP assertions for scripts/smoke-deploy.sh, run inside a disposable container on the reference
// deployment's `edge` network: every request goes through the proxy by its public hostname, exactly
// as a browser's would. Not a test of the API in isolation, and never run against a real
// installation.
//
// node deploy-smoke-client.mjs edge an ordinary, untrusted peer of the proxy
// node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address
// node deploy-smoke-client.mjs session the initial owner signing in and out through the proxy
//
// Either topology (ADR 0045): SMOKE_HOSTNAME is the API's hostname, and SMOKE_DASHBOARD_HOSTNAME,
// when set, the Dashboard's. Unset means the same-origin topology, where both are one hostname. Every
// request goes to the API's origin and carries the Dashboard's origin as `Origin`, as a browser would.
//
// Output is one line per check; any failed check exits non-zero. Nothing printed carries a
// credential, cookie, signed URL, or response body beyond the fixed shapes asserted here.
import { connect as connectHttp2 } from "node:http2";
const mode = process.argv[2];
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
const dashboardOrigin = process.env.SMOKE_DASHBOARD_HOSTNAME
? `https://${process.env.SMOKE_DASHBOARD_HOSTNAME}`
: apiOrigin;
const split = dashboardOrigin !== apiOrigin;
const untrustedOrigin = "https://untrusted.smoke.test";
function required(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
}
let failures = 0;
function check(condition, description) {
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
if (!condition) {
failures += 1;
}
}
/** A sign-in attempt that can only fail authentication; what matters is whether the API's
* per-client limiter (3 attempts per 10 seconds) counts it against the expected client. */
async function signInAttempt(headers = {}) {
const response = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email: "[email protected]", password: "definitely-not-the-password" }),
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST",
});
await response.arrayBuffer();
return response.status;
}
async function edgeChecks() {
const ready = await fetch(`${apiOrigin}/api/v1/health/ready`);
check(
ready.status === 200 && (await ready.text()) === '{"status":"ready"}',
"GET /api/v1/health/ready through the proxy is 200 ready",
);
const page = await fetch(`${dashboardOrigin}/`);
const html = await page.text();
check(page.status === 200, "GET / through the proxy is 200");
check(
page.headers.get("content-type") === "text/html; charset=utf-8" &&
html.includes('id="ekklesia-host"') &&
html.includes('src="/assets/ekklesia-dashboard/app.js"'),
"GET / is the Dashboard HTML",
);
check(
page.headers.get("x-frame-options") === "DENY" &&
page.headers.get("content-security-policy") === "frame-ancestors 'none'" &&
page.headers.get("referrer-policy") === "no-referrer",
"the Dashboard page keeps its security headers through the proxy",
);
check(
html.includes(`<meta content="${apiOrigin}" name="ekklesia-api-origin" />`),
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
);
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
await script.arrayBuffer();
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
const outside = await fetch(`${dashboardOrigin}/favicon.ico`);
check(
outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"),
"a path the API does not serve is refused by the proxy, never forwarded",
);
const unknownApi = await fetch(`${apiOrigin}/api/v1/does-not-exist`);
check(
unknownApi.status === 404 && (await unknownApi.text()) === '{"code":"NOT_FOUND"}',
"an unknown /api/* path reaches the API and gets its sanitized 404",
);
if (split) {
await splitRoutingChecks();
await splitCorsChecks();
}
await storageDeliveryCheck();
// Every caller-supplied forwarding header rotates on every attempt. If any of them reached the
// API's client-address resolution, each attempt would land in a fresh limiter bucket and the
// fourth would not be limited.
const statuses = [];
for (let attempt = 1; attempt <= 4; attempt += 1) {
statuses.push(
await signInAttempt({
"cf-connecting-ip": `198.51.100.${attempt}`,
forwarded: `for=198.51.100.${attempt + 10}`,
"true-client-ip": `198.51.100.${attempt + 20}`,
"x-ekklesia-client-address": `198.51.100.${attempt + 50}`,
"x-forwarded-for": `198.51.100.${attempt + 30}`,
"x-real-ip": `198.51.100.${attempt + 40}`,
}),
);
}
check(
statuses.slice(0, 3).every((status) => status !== 429 && status < 500) && statuses[3] === 429,
`spoofed forwarding headers do not choose the client address (statuses ${statuses.join(",")})`,
);
}
/** A proxy refusal: Caddy's own empty `404`, never the API's JSON one, so the request was not
* forwarded at all. */
async function refusedByProxy(url) {
const response = await fetch(url);
const body = await response.text();
return response.status === 404 && body === "";
}
/** Each hostname serves exactly its own paths (ADR 0045, "Routing per hostname"). */
async function splitRoutingChecks() {
check(
await refusedByProxy(`${dashboardOrigin}/api/v1/health/ready`),
"the Dashboard hostname refuses /api/*, so the session cookie has one home",
);
check(
await refusedByProxy(`${dashboardOrigin}/display/example`),
"the Dashboard hostname refuses /display/*",
);
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /");
check(
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
"the API hostname refuses the Dashboard's script",
);
const display = await fetch(`${apiOrigin}/display/does-not-exist`);
check(
display.status === 404 && (await display.text()) === '{"code":"NOT_FOUND"}',
"the API hostname forwards /display/* to the API (the display host's origin)",
);
}
/** Credentialed CORS for exactly the Dashboard's origin, and for nothing else. */
async function splitCorsChecks() {
const preflight = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
headers: {
"access-control-request-headers": "content-type",
"access-control-request-method": "POST",
origin: dashboardOrigin,
},
method: "OPTIONS",
});
await preflight.arrayBuffer();
check(
preflight.status === 204 &&
preflight.headers.get("access-control-allow-origin") === dashboardOrigin &&
preflight.headers.get("access-control-allow-credentials") === "true",
"a preflight from the Dashboard's origin is allowed, with credentials",
);
const foreignPreflight = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
headers: { "access-control-request-method": "POST", origin: untrustedOrigin },
method: "OPTIONS",
});
await foreignPreflight.arrayBuffer();
check(
foreignPreflight.headers.get("access-control-allow-origin") === null,
"a preflight from any other origin gets no CORS permission",
);
const principal = await fetch(`${apiOrigin}/api/v1/principal`, {
headers: { origin: dashboardOrigin },
});
await principal.arrayBuffer();
check(
principal.headers.get("access-control-allow-origin") === dashboardOrigin &&
principal.headers.get("access-control-allow-credentials") === "true" &&
(principal.headers.get("vary") ?? "").includes("Origin"),
"an API response to the Dashboard's origin reflects exactly that origin, with credentials",
);
}
/**
* One GET over a real HTTP/2 connection. Node's fetch speaks HTTP/1.1, and the failure this guards
* against exists only on HTTP/2: a reset stream, which a browser reports as ERR_HTTP2_PROTOCOL_ERROR.
*/
function http2Get(url, headers = {}) {
return new Promise((resolve) => {
const session = connectHttp2(url.origin);
const finish = (result) => {
clearTimeout(timer);
session.close();
resolve(result);
};
const timer = setTimeout(() => finish({ error: "timeout" }), 10_000);
session.on("error", () => finish({ error: "session" }));
const stream = session.request({
":method": "GET",
":path": `${url.pathname}${url.search}`,
...headers,
});
let status;
let responseHeaders = {};
let bytes = 0;
stream.on("response", (received) => {
status = received[":status"];
responseHeaders = received;
});
stream.on("data", (chunk) => {
bytes += chunk.length;
});
stream.on("error", (error) => finish({ error: error.code ?? "stream" }));
stream.on("end", () => finish({ bytes, headers: responseHeaders, status }));
stream.end();
});
}
/** The CORS headers of a response, by name: what a browser would act on. */
function corsOf(response) {
return [...response.headers.keys()].filter((name) => name.startsWith("access-control-"));
}
async function storageDeliveryCheck() {
// Core's own provider, from the image, exactly as the API composes it: it reaches object-storage
// over HTTPS on the private network and signs read URLs for the storage hostname.
const { createS3StorageProvider, createStorageProviderConfig } = await import(
"/opt/ekklesia/core/dist/storage/index.js"
);
const config = createStorageProviderConfig(
{
accessKey: required("S3_ACCESS_KEY"),
bucketPrefix: "ekklesia-smoke",
deliveryEndpoint: `https://${required("SMOKE_STORAGE_HOSTNAME")}`,
endpoint: "https://object-storage:8443",
forcePathStyle: true,
region: "us-east-1",
secretKey: required("S3_SECRET_KEY"),
},
{ environment: "production" },
);
const provider = createS3StorageProvider(config);
try {
const bucket = "ekklesia-smoke-delivery";
const body = new TextEncoder().encode("delivered through the proxy\n");
await provider.ensureBucket(bucket);
await provider.put(bucket, "smoke/object.txt", body, {
contentLength: body.byteLength,
contentType: "text/plain",
});
const readUrl = await provider.createReadUrl(bucket, "smoke/object.txt", {
expiresInSeconds: 300,
});
const url = new URL(readUrl.url);
check(
url.origin === `https://${required("SMOKE_STORAGE_HOSTNAME")}`,
"a signed read URL names the storage hostname",
);
const delivered = await fetch(url);
const text = await delivered.text();
check(
delivered.status === 200 && text === "delivered through the proxy\n",
"a signed read URL verifies through the proxy (original Host preserved)",
);
check(
(delivered.headers.get("cache-control") ?? "").startsWith("private"),
"a delivered object is Cache-Control: private",
);
await provider.delete(bucket, "smoke/object.txt");
// The same image, twice, over HTTP/2: a browser's second load of a signed URL it has cached is a
// conditional request, and must come back as a clean 304, not a reset stream.
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
await provider.put(bucket, "smoke/image.png", png, {
contentLength: png.byteLength,
contentType: "image/png",
});
const image = new URL(
(await provider.createReadUrl(bucket, "smoke/image.png", { expiresInSeconds: 300 })).url,
);
const first = await http2Get(image);
check(
first.status === 200 && first.bytes === png.byteLength && Boolean(first.headers?.etag),
"an image loads over HTTP/2 through the proxy, with a validator (200, ETag)",
);
const revalidated = await http2Get(image, { "if-none-match": first.headers?.etag ?? "" });
check(
revalidated.status === 304 && revalidated.error === undefined,
`the same image loaded again over HTTP/2 revalidates cleanly (${revalidated.status ?? revalidated.error})`,
);
const sinceModified = await http2Get(image, {
"if-modified-since": first.headers?.["last-modified"] ?? "",
});
check(
sinceModified.status === 304 && sinceModified.error === undefined,
`a revalidation by date over HTTP/2 is a clean 304 too (${sinceModified.status ?? sinceModified.error})`,
);
// Delivery-layer CORS is the deployment's own, set by the proxy: exactly the origins whose pages
// read objects with fetch(), GET and HEAD only, and nothing of the object store's own answer.
const fromApi = await fetch(image, { headers: { origin: apiOrigin } });
check(
fromApi.headers.get("access-control-allow-origin") === apiOrigin &&
(fromApi.headers.get("vary") ?? "").toLowerCase().includes("origin") &&
corsOf(fromApi).length === 1,
"a read from the API origin is allowed for exactly that origin, with Vary: Origin and nothing else",
);
if (split) {
const fromDashboard = await fetch(image, { headers: { origin: dashboardOrigin } });
check(
fromDashboard.headers.get("access-control-allow-origin") === dashboardOrigin,
"a read from the Dashboard origin is allowed for exactly that origin",
);
}
const fromElsewhere = await fetch(image, { headers: { origin: untrustedOrigin } });
check(
fromElsewhere.status === 200 && corsOf(fromElsewhere).length === 0,
"a read from any other origin gets no CORS header at all",
);
const preflight = await fetch(image, {
headers: { "access-control-request-method": "GET", origin: apiOrigin },
method: "OPTIONS",
});
check(
preflight.status === 204 &&
preflight.headers.get("access-control-allow-origin") === apiOrigin &&
preflight.headers.get("access-control-allow-methods") === "GET, HEAD" &&
preflight.headers.get("x-amz-request-id") === null,
"the proxy answers a preflight itself (204, GET and HEAD only), never the object store",
);
const foreignPreflight = await fetch(image, {
headers: { "access-control-request-method": "GET", origin: untrustedOrigin },
method: "OPTIONS",
});
check(
foreignPreflight.status === 204 && corsOf(foreignPreflight).length === 0,
"a preflight from any other origin is answered without permission",
);
await provider.delete(bucket, "smoke/image.png");
} finally {
await provider.close();
}
}
async function tunnelChecks() {
// From the connector's fixed address, CF-Connecting-IP is the client: one visitor is limited on
// its fourth attempt, and a different visitor is not limited by the first one's attempts.
const first = [];
for (let attempt = 1; attempt <= 4; attempt += 1) {
first.push(await signInAttempt({ "cf-connecting-ip": "203.0.113.10" }));
}
check(
first.slice(0, 3).every((status) => status !== 429 && status < 500) && first[3] === 429,
`the connector's CF-Connecting-IP identifies the visitor (statuses ${first.join(",")})`,
);
const second = await signInAttempt({ "cf-connecting-ip": "203.0.113.11" });
check(
second !== 429 && second < 500,
`a second visitor behind the connector has its own limiter bucket (status ${second})`,
);
}
/** The deployed browser authentication contract (launch checklist item 9), as the initial owner
* created by the image's own CLI (item 10). */
async function sessionChecks() {
for (const path of [
"/api/auth/get-session",
"/api/auth/list-sessions",
"/api/auth/update-session",
"/api/auth/change-password",
]) {
const response = await fetch(`${apiOrigin}${path}`, {
method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST",
headers: { origin: dashboardOrigin },
});
check(
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
`${path} is unavailable through the proxy`,
);
}
const signIn = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
body: JSON.stringify({
email: required("SMOKE_OWNER_EMAIL"),
password: required("SMOKE_OWNER_PASSWORD"),
}),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const signInBody = await signIn.text();
const setCookie = signIn.headers.get("set-cookie") ?? "";
check(
signIn.status === 200 && signInBody === '{"ok":true}',
'sign-in returns the fixed {"ok":true}',
);
const attributes = setCookie.toLowerCase();
check(
attributes.includes("httponly") &&
attributes.includes("secure") &&
attributes.includes("samesite=lax") &&
!attributes.includes("domain="),
"the session cookie is host-only, HttpOnly, Secure, and SameSite=Lax",
);
const cookie = setCookie.split(";")[0] ?? "";
const principal = await fetch(`${apiOrigin}/api/v1/principal`, {
headers: { cookie, origin: dashboardOrigin },
});
const principalBody = await principal.json();
check(
principal.status === 200 && principalBody.kind === "authenticated",
"the session resolves an authenticated principal through the proxy",
);
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
body: "{}",
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
method: "POST",
});
await signOut.arrayBuffer();
const after = await fetch(`${apiOrigin}/api/v1/principal`, {
headers: { cookie, origin: dashboardOrigin },
});
check(
signOut.status === 200 && (await after.json()).kind === "anonymous",
"sign-out ends the session",
);
}
if (mode === "edge") {
await edgeChecks();
} else if (mode === "tunnel") {
await tunnelChecks();
} else if (mode === "session") {
await sessionChecks();
} else {
throw new Error('Mode must be "edge", "tunnel", or "session".');
}
if (failures > 0) {
console.log(`${failures} check(s) failed.`);
process.exit(1);
}
+979
View File
@@ -0,0 +1,979 @@
// Real-browser display-device scenarios for scripts/smoke-deploy.sh in the split topology (ADR 0036,
// ADR 0045). Runs inside a disposable container on the deployment's `edge` network and drives two
// headless Chromiums over the Chrome DevTools Protocol, with Node.js's own fetch and WebSocket and no
// other dependency: the smoke run's browser, where the owner signs in, and a "kiosk" browser whose
// profile directory is a Docker volume that outlives the kiosk container.
//
// node deploy-smoke-display.mjs <step>
//
// pair seed content, pair the kiosk, then close its browser cleanly
// restart same profile, new browser process: still paired and live?
// two-pages two pages, one device credential: does either flap?
// connectivity-blip the browser reports offline then online: is a live display remounted?
// session-blip Signage's first viewer-session request fails once: does the display recover?
// stream-failure the first realtime connection is refused: is realtime re-established?
// caching-probe can a display on the API origin store storage-origin images in Cache Storage?
// stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline
// revoke revoke the paired kiosk while its display is open
//
// Every DevTools command and every page-side request has its own deadline, and a step has an overall
// budget. A call that never answers fails the step with a line naming that exact call, because a call
// that can hang the driver may be a request that can hang a real display.
//
// Output is one line per check or observation; nothing printed carries a credential, cookie value,
// signed URL, query string, or response body.
function required(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
}
/** A positive whole number of milliseconds from the environment, or the default. */
function milliseconds(name, fallback) {
const raw = process.env[name];
const value = raw === undefined || raw === "" ? fallback : Number(raw);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive whole number of milliseconds.`);
}
return value;
}
const mode = process.argv[2];
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
const dashboardOrigin = `https://${required("SMOKE_DASHBOARD_HOSTNAME")}`;
const ownerAddress = required("SMOKE_CDP_ADDRESS");
const kioskAddress = required("SMOKE_KIOSK_CDP_ADDRESS");
const watchMs = milliseconds("SMOKE_WATCH_MS", 120_000);
const recycleWatchMs = milliseconds("SMOKE_RECYCLE_WATCH_MS", 330_000);
const stepBudgetMs = milliseconds("SMOKE_STEP_BUDGET_MS", 180_000);
/** How long one DevTools command may go unanswered. */
const callTimeoutMs = milliseconds("SMOKE_CALL_TIMEOUT_MS", 10_000);
/** How long one page-side HTTP request may go unanswered; shorter than the call deadline, so a hung
* request is reported as the request rather than as the evaluation that awaited it. */
const requestTimeoutMs = Math.max(1_000, callTimeoutMs - 2_000);
const waitTimeoutMs = 30_000;
const deviceCookie = "ekklesia_device_session";
/** Far above what three cached slides need, which is one object URL each. */
const MAX_LIVE_OBJECT_URLS = 50;
const stepStartedAt = Date.now();
let failures = 0;
function check(condition, description) {
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
if (!condition) {
failures += 1;
}
}
function note(description) {
console.log(` ${description}`);
}
const clock = (ms = Date.now()) => new Date(ms).toISOString().slice(11, 19);
const since = (from) => `+${Math.round((Date.now() - from) / 1000)}s`;
function progress(description) {
console.log(`···· ${clock()} ${since(stepStartedAt)} ${description}`);
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** A URL's path only: a signed delivery URL's query is a credential. */
const pathOf = (url) => {
try {
return new URL(url).pathname;
} catch {
return "(unparseable)";
}
};
// --- Hang guards ---------------------------------------------------------------------------------
class HangError extends Error {
constructor(label, ms) {
super(`HANG ${label}: no answer within ${ms / 1000}s`);
this.name = "HangError";
}
}
/** Everything awaited right now, by name, so a step that overruns its budget says what it was
* waiting for. */
const inFlight = new Map();
let nextOperation = 1;
function guard(label, promise, ms = callTimeoutMs) {
const id = nextOperation;
nextOperation += 1;
inFlight.set(id, { at: Date.now(), label });
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new HangError(label, ms)), ms);
}),
]).finally(() => {
clearTimeout(timer);
inFlight.delete(id);
});
}
function describeInFlight() {
const pending = [...inFlight.values()].map(
(operation) => `${operation.label} (${Math.round((Date.now() - operation.at) / 1000)}s)`,
);
return pending.length === 0 ? "nothing — the step itself overran" : pending.join("; ");
}
setTimeout(() => {
console.log(
`FAIL ${mode}: step budget of ${stepBudgetMs / 1000}s exceeded; in flight: ${describeInFlight()}`,
);
process.exit(2);
}, stepBudgetMs).unref();
// --- DevTools ------------------------------------------------------------------------------------
/** The same minimal DevTools client as deploy-smoke-browser.mjs, with a deadline on every command. */
async function connect(devtools, name) {
const deadline = Date.now() + 30_000;
let version;
while (version === undefined) {
try {
const response = await fetch(`http://${devtools}/json/version`, {
signal: AbortSignal.timeout(2_000),
});
version = await response.json();
} catch {
if (Date.now() > deadline) {
throw new HangError(`the ${name} browser's DevTools endpoint`, 30_000);
}
await sleep(500);
}
}
const socketUrl = version.webSocketDebuggerUrl.replace(/^ws:\/\/[^/]+/u, `ws://${devtools}`);
const socket = new WebSocket(socketUrl);
await guard(
`the ${name} browser's DevTools socket`,
new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", () => reject(new Error("DevTools connection failed.")), {
once: true,
});
}),
);
let nextId = 1;
const pending = new Map();
const listeners = [];
socket.addEventListener("message", (event) => {
const message = JSON.parse(String(event.data));
if (message.id !== undefined) {
const waiter = pending.get(message.id);
pending.delete(message.id);
if (message.error) {
waiter?.reject(new Error(`DevTools ${waiter.method} failed.`));
} else {
waiter?.resolve(message.result);
}
return;
}
for (const listener of listeners) {
listener(message);
}
});
return {
close: () => socket.close(),
on: (listener) => listeners.push(listener),
send(method, params = {}, sessionId = undefined, where = name) {
const id = nextId;
nextId += 1;
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
return guard(
`DevTools ${method} on ${where}`,
new Promise((resolve, reject) => pending.set(id, { method, reject, resolve })),
);
},
};
}
/** Finds a test id anywhere in the document, including inside module artifacts' open shadow roots
* (ADR 0040). */
const deepFind = `((testId) => {
const walk = (root) => {
const found = root.querySelector('[data-testid="' + testId + '"]');
if (found) return found;
for (const element of root.querySelectorAll("*")) {
if (element.shadowRoot) {
const inner = walk(element.shadowRoot);
if (inner) return inner;
}
}
return null;
};
return walk(document);
})`;
const find = (testId) => `${deepFind}(${JSON.stringify(testId)})`;
/** Installed before any page script: the browser's own connectivity events and the display service
* worker's fallback reports, which are what the display host remounts on (ADR 0043). */
const instrumentation = `(() => {
const events = [];
// Object URLs created and revoked in this document. Each live one pins its blob in the browser's
// blob storage until it is revoked or the document goes away.
const blobs = { created: 0, revoked: 0 };
Object.defineProperty(window, "__displayRepro", { value: { blobs, events } });
const create = URL.createObjectURL.bind(URL);
const revoke = URL.revokeObjectURL.bind(URL);
URL.createObjectURL = (object) => { blobs.created += 1; return create(object); };
URL.revokeObjectURL = (url) => { blobs.revoked += 1; return revoke(url); };
const record = (kind) => events.push({ at: Date.now(), kind });
addEventListener("online", () => record("online"));
addEventListener("offline", () => record("offline"));
navigator.serviceWorker?.addEventListener("message", (event) => {
const status = event.data && event.data.ekklesiaDisplayOffline;
if (status) record(status.usingFallback ? "sw-fallback:" + (status.kind || "") : "sw-network");
});
})()`;
/** One page target with its own flat session, and what it does on the network. */
async function openPage(cdp, url, label) {
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await cdp.send("Target.attachToTarget", { flatten: true, targetId });
const send = (method, params) => cdp.send(method, params, sessionId, `page "${label}"`);
const requests = new Map();
const page = {
label,
/** Every realtime connection: when it opened, its status, and when and how it ended. */
streams: [],
/** Signage viewer-session requests: one per module mount. */
sessionRequests: [],
/** Heartbeat statuses, by status code. */
heartbeats: new Map(),
/** Channel-state fetches and how they went. */
stateFetches: [],
/** Non-2xx responses and failed requests, by path only. */
problems: [],
};
cdp.on((message) => {
if (message.sessionId !== sessionId) {
return;
}
const params = message.params ?? {};
if (message.method === "Network.requestWillBeSent") {
const path = pathOf(params.request.url);
const entry = { at: Date.now(), path, type: params.type };
requests.set(params.requestId, entry);
if (params.type === "EventSource") {
entry.stream = {
openedAt: Date.now(),
path,
status: undefined,
endedAt: undefined,
how: undefined,
};
page.streams.push(entry.stream);
} else if (path.endsWith("/viewers/session")) {
page.sessionRequests.push(Date.now());
} else if (path.endsWith("/display-state") && !path.includes("module-realtime")) {
entry.state = { at: Date.now(), outcome: "pending" };
page.stateFetches.push(entry.state);
}
} else if (message.method === "Network.responseReceived") {
const entry = requests.get(params.requestId);
const { status } = params.response;
if (entry?.stream) {
entry.stream.status = status;
}
if (entry?.state) {
entry.state.outcome = String(status);
}
if (entry?.path.endsWith("/heartbeat")) {
page.heartbeats.set(status, (page.heartbeats.get(status) ?? 0) + 1);
}
if (status >= 400 || status === 0) {
page.problems.push(`${clock()} ${status} ${pathOf(params.response.url)}`);
}
} else if (message.method === "Network.loadingFinished") {
const entry = requests.get(params.requestId);
if (entry?.stream) {
entry.stream.endedAt = Date.now();
entry.stream.how = "finished";
}
} else if (message.method === "Network.loadingFailed") {
const entry = requests.get(params.requestId);
if (entry?.stream) {
entry.stream.endedAt = Date.now();
entry.stream.how = params.canceled ? "canceled" : `failed ${params.errorText}`;
}
if (entry?.state) {
entry.state.outcome = params.canceled ? "canceled" : `failed ${params.errorText}`;
}
if (entry && !params.canceled) {
page.problems.push(`${clock()} failed ${params.errorText} ${entry.path}`);
}
}
});
await send("Network.enable");
await send("Page.enable");
await send("Runtime.enable");
await send("Page.addScriptToEvaluateOnNewDocument", { source: instrumentation });
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 1,
height: 1080,
mobile: false,
width: 1920,
});
page.send = send;
/** Runs an expression in the page. `what` names it if it never comes back. */
page.evaluate = async (expression, what = "an evaluation") => {
const result = await guard(
`page "${label}": ${what}`,
send("Runtime.evaluate", { awaitPromise: true, expression, returnByValue: true }),
);
if (result.exceptionDetails) {
throw new Error(`A page expression threw (${what}).`);
}
return result.result.value;
};
page.waitFor = async (expression, description, ms = waitTimeoutMs) => {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (
await page
.evaluate(`Boolean(${expression})`, `waiting for ${description}`)
.catch((error) => {
if (error instanceof HangError) throw error;
return false;
})
) {
return true;
}
await sleep(250);
}
note(`timed out waiting for ${description}`);
return false;
};
/** A credentialed request from this page, bounded in the page itself, so a request that never
* answers is reported as that request. */
page.request = async (method, url, body) => {
const result = await page.evaluate(
`fetch(${JSON.stringify(url)}, {
credentials: "include",
method: ${JSON.stringify(method)},
signal: AbortSignal.timeout(${requestTimeoutMs}),
${body === undefined ? "" : `headers: { "content-type": "application/json" }, body: ${JSON.stringify(JSON.stringify(body))},`}
}).then(
async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
)`,
`${method} ${pathOf(url)}`,
);
if (result.status === "timeout") {
throw new HangError(`HTTP ${method} ${pathOf(url)} from page "${label}"`, requestTimeoutMs);
}
return result;
};
page.close = () => cdp.send("Target.closeTarget", { targetId });
page.navigate = (to) => send("Page.navigate", { url: to });
if (url) {
await page.navigate(url);
}
return page;
}
/** The owner, signed in on the Dashboard hostname of the smoke run's own browser. */
async function ownerPage() {
const cdp = await connect(ownerAddress, "owner");
const page = await openPage(cdp, `${dashboardOrigin}/`, "owner");
await page.waitFor(`${find("login-email")} || ${find("host-shell")}`, "the Dashboard");
if (!(await page.evaluate(`Boolean(${find("host-shell")})`, "checking the session"))) {
await page.evaluate(
`(() => {
const setValue = (element, value) => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(element, value);
element.dispatchEvent(new Event("input", { bubbles: true }));
};
setValue(${find("login-email")}, ${JSON.stringify(required("SMOKE_OWNER_EMAIL"))});
setValue(${find("login-password")}, ${JSON.stringify(required("SMOKE_OWNER_PASSWORD"))});
${find("login-email")}.form.requestSubmit();
})()`,
"submitting the sign-in form",
);
check(await page.waitFor(find("host-shell"), "the signed-in Dashboard"), "the owner signs in");
}
page.api = (method, path, body) => page.request(method, `${apiOrigin}${path}`, body);
return { cdp, page };
}
/** Closes the owner's tab as well as the connection: the owner browser is shared by every step, and a
* tab left behind by each one accumulates until the Dashboard no longer renders in time. */
async function finishOwner(owner) {
await owner.page.close().catch(() => undefined);
owner.cdp.close();
}
async function displayModuleId(owner) {
const navigation = await owner.api("GET", "/api/v1/dashboard/navigation");
const entries = [
...navigation.body.ungrouped,
...navigation.body.groups.flatMap((group) => group.modules),
];
const withDisplay = entries.filter((entry) => entry.module.display !== null);
const signage = withDisplay.find((entry) => entry.module.id === "signage");
return (signage ?? withDisplay[0])?.module.id;
}
/** Three slides of plain colour, on a five-second rotation, so a display has real content to persist
* and continue from, as the production screen did. Signage only. */
async function seedSignage(owner) {
const base = "/api/v1/modules/signage";
const images = [];
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
const uploaded = await owner.evaluate(
`(async () => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 360;
const context = canvas.getContext("2d");
context.fillStyle = ${JSON.stringify(colour)};
context.fillRect(0, 0, 640, 360);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
const response = await fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
signal: AbortSignal.timeout(${requestTimeoutMs}),
});
return { status: response.status, body: await response.json().catch(() => null) };
})()`,
`uploading slide ${index}`,
);
images.push(uploaded.body?.image?.id);
}
check(
images.every((id) => typeof id === "string"),
"three slides are uploaded to Signage",
);
const timeline = await owner.api("POST", `${base}/timelines`, { name: "Display repro" });
const timelineId = timeline.body?.timeline?.id ?? timeline.body?.id;
for (const itemId of images) {
await owner.api("POST", `${base}/timelines/${timelineId}/slots`, {
durationOverrideMs: 5_000,
itemId,
providerId: "signage.native-images",
});
}
const groups = await owner.api("GET", `${base}/channel-groups`);
const groupId = groups.body?.groups?.[0]?.id;
const activated = await owner.api("POST", `${base}/playback/${groupId}/activate`, {
timelineId,
});
check(activated.status === 200, "a three-slide timeline is playing on the default channel group");
}
async function signageViewers(owner) {
const listed = await owner.api("GET", "/api/v1/modules/signage/viewers");
return listed.body?.viewers ?? [];
}
async function coreDevices(owner) {
const listed = await owner.api("GET", "/api/v1/devices");
return listed.body?.devices ?? [];
}
/** The display's own view of itself: which host state is showing, and Signage's offline flag. */
function displayState(page) {
return page.evaluate(
`(() => {
const stage = ${find("signage-stage")};
return {
pairing: Boolean(${find("display-pairing")}),
hostLoading: Boolean(${find("display-host-loading")}) || Boolean(${find("display-loading")}),
hostError: Boolean(${find("display-host-error")}) || Boolean(${find("display-error")}),
hostOffline: Boolean(${find("display-offline")}),
mounted: (${find("display-module-container")}?.childElementCount ?? 0) > 0,
signageOffline: stage ? stage.dataset.offline === "true" : null,
};
})()`,
"reading the display state",
);
}
async function blobCounts(page) {
return page.evaluate(
"window.__displayRepro ? window.__displayRepro.blobs : { created: 0, revoked: 0 }",
"reading object-URL counts",
);
}
async function connectivityEvents(page) {
return page.evaluate(
"window.__displayRepro ? window.__displayRepro.events : []",
"reading connectivity events",
);
}
async function describeDeviceCookie(cdp) {
const { cookies } = await cdp.send("Storage.getCookies");
const cookie = cookies.find((candidate) => candidate.name === deviceCookie);
if (!cookie) {
note(`${deviceCookie}: absent from the profile`);
return undefined;
}
const days = cookie.session
? "session"
: `${((cookie.expires * 1000 - Date.now()) / 86_400_000).toFixed(1)} days`;
note(
`${deviceCookie}: domain=${cookie.domain} session=${cookie.session} lifetime=${days} httpOnly=${cookie.httpOnly} secure=${cookie.secure} sameSite=${cookie.sameSite}`,
);
return cookie;
}
const liveExpression = `(() => {
const stage = ${find("signage-stage")};
const mounted = (${find("display-module-container")}?.childElementCount ?? 0) > 0;
return mounted && !${find("display-pairing")} && (stage === null || stage.dataset.offline === "false");
})()`;
function waitLive(page, description, ms = 45_000) {
return page.waitFor(liveExpression, description, ms);
}
/** A page's network story, one line each, by path only. */
function summarize(page, from) {
const at = (ms) => (ms === undefined ? "-" : `${clock(ms)} (${Math.round((ms - from) / 1000)}s)`);
note(`${page.label}: viewer sessions opened ${page.sessionRequests.length} (= module mounts)`);
note(
`${page.label}: heartbeats ${[...page.heartbeats].map(([status, count]) => `${status}×${count}`).join(" ") || "none"}`,
);
const states = page.stateFetches.reduce((tally, fetch) => {
tally[fetch.outcome] = (tally[fetch.outcome] ?? 0) + 1;
return tally;
}, {});
note(`${page.label}: channel-state fetches ${JSON.stringify(states)}`);
for (const stream of page.streams) {
const lived =
stream.endedAt === undefined
? "open"
: `${Math.round((stream.endedAt - stream.openedAt) / 1000)}s`;
note(
`${page.label}: stream opened ${at(stream.openedAt)} status ${stream.status ?? "-"} ended ${at(stream.endedAt)} ${stream.how ?? ""} lived ${lived}`,
);
}
note(`${page.label}: problems ${page.problems.slice(-12).join(" | ") || "none"}`);
}
/** Samples display state on an interval for a fixed time, printing progress, and returns a tally. */
async function watch(pages, ms, { onSample, viewerOwner } = {}) {
const from = Date.now();
const tally = new Map(
pages.map((page) => [
page.label,
{ loading: 0, offline: 0, pairing: 0, samples: 0, transitions: [] },
]),
);
const last = new Map();
let nextProgress = from + 15_000;
const roster = [];
while (Date.now() - from < ms) {
for (const page of pages) {
const state = await displayState(page);
const t = tally.get(page.label);
t.samples += 1;
if (state.signageOffline || state.hostOffline) t.offline += 1;
if (state.pairing) t.pairing += 1;
if (state.hostLoading) t.loading += 1;
const key = JSON.stringify(state);
if (last.get(page.label) !== key) {
last.set(page.label, key);
t.transitions.push(`${clock()} ${since(from)} ${key}`);
}
if (onSample && (await onSample(page, state))) {
return { from, roster, stopped: true, tally };
}
}
if (Date.now() >= nextProgress) {
nextProgress = Date.now() + 15_000;
if (viewerOwner) {
const viewers = await signageViewers(viewerOwner);
roster.push(
`${since(from)} ${viewers.map((v) => `${v.name}:${v.online ? "Online" : "Offline"}`).join(",")}`,
);
}
const lines = [];
for (const page of pages) {
const t = tally.get(page.label);
const blobs = await blobCounts(page);
lines.push(
`${page.label}: ${t.samples} samples, offline ${t.offline}, mounts ${page.sessionRequests.length}, streams ${page.streams.length}, live object URLs ${blobs.created - blobs.revoked}`,
);
}
progress(lines.join(" · "));
}
await sleep(2_000);
}
return { from, roster, stopped: false, tally };
}
function printTally(result) {
for (const [label, t] of result.tally) {
note(
`${label}: ${t.samples} samples, offline in ${t.offline}, pairing in ${t.pairing}, loading in ${t.loading}`,
);
for (const transition of t.transitions.slice(0, 30)) {
note(` ${label} ${transition}`);
}
}
if (result.roster.length > 0) {
note(`roster over time: ${result.roster.join(" ; ")}`);
}
}
// --- Steps ---------------------------------------------------------------------------------------
async function run() {
if (mode === "pair") {
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
check(moduleId !== undefined, `a module publishes a display (${moduleId})`);
if (moduleId === "signage") {
await seedSignage(owner.page);
}
const kiosk = await connect(kioskAddress, "kiosk");
const display = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "kiosk");
check(
await display.waitFor(find("display-pairing-code"), "a pairing code"),
"an unpaired kiosk shows a pairing code",
);
const code = (
await display.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code")
).replace(/\D/gu, "");
const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", {
code,
deviceName: "Repro kiosk",
});
check(approved.status === 200, "the owner approves the code");
check(
await waitLive(display, "the paired display to go live"),
"the paired kiosk mounts the display and is live",
);
await describeDeviceCookie(kiosk);
progress("letting heartbeats, a persisted snapshot, and media prefetch happen (20s)");
await sleep(20_000);
for (const viewer of await signageViewers(owner.page)) {
note(
`signage viewer "${viewer.name}" online=${viewer.online} offlineReady=${viewer.offlineReady} cachedAssets=${viewer.cachedAssetCount} error=${viewer.lastError?.code ?? "none"}`,
);
}
summarize(display, stepStartedAt);
// A clean exit: the browser shuts down normally, exactly as a systemd stop of a well-behaved kiosk.
await kiosk.send("Browser.close").catch(() => undefined);
kiosk.close();
await finishOwner(owner);
} else if (mode === "restart") {
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const cookie = await describeDeviceCookie(kiosk);
check(cookie !== undefined, "the device credential survived a clean browser exit");
const display = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "kiosk");
const live = await waitLive(display, "the restarted display to go live");
const state = await displayState(display);
check(
live && !state.pairing,
`after a clean restart the kiosk is still paired and live (${JSON.stringify(state)})`,
);
summarize(display, stepStartedAt);
await display.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "two-pages") {
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const url = `${apiOrigin}/display/${moduleId}`;
const first = await openPage(kiosk, url, "first");
check(await waitLive(first, "the first page"), "the first page is live");
const second = await openPage(kiosk, url, "second");
check(await waitLive(second, "the second page"), "the second page is live");
const result = await watch([first, second], watchMs, { viewerOwner: owner.page });
printTally(result);
for (const page of [first, second]) {
summarize(page, result.from);
note(`${page.label}: connectivity events ${JSON.stringify(await connectivityEvents(page))}`);
}
check(
[...result.tally.values()].every((t) => t.offline === 0),
`neither page showed an offline state while both were open (${watchMs / 1000}s)`,
);
check(
[first, second].every((page) => page.sessionRequests.length === 1),
"neither page was remounted while both were open",
);
await first.close();
await second.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "connectivity-blip") {
// The browser's own offline/online events, as a Wi-Fi reassociation or a link flap produces. The
// display host re-resolves its descriptor on "online" (ADR 0043); this measures what that costs.
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "blip");
check(await waitLive(page, "the display"), "the display is live");
const mountsBefore = page.sessionRequests.length;
await page.evaluate(
`dispatchEvent(new Event("offline")); setTimeout(() => dispatchEvent(new Event("online")), 1000)`,
"dispatching offline then online",
);
let firstOfflineAt;
let lastOfflineAt;
const result = await watch([page], 30_000, {
onSample: (_page, state) => {
if (state.signageOffline || state.hostOffline || state.hostLoading || !state.mounted) {
firstOfflineAt ??= Date.now();
lastOfflineAt = Date.now();
}
return false;
},
});
printTally(result);
const remounts = page.sessionRequests.length - mountsBefore;
note(`remounts caused by one offline/online pair: ${remounts}`);
note(
firstOfflineAt === undefined
? "nothing visible changed"
: `visible disruption from ${clock(firstOfflineAt)} to ${clock(lastOfflineAt)} (sampled every 2s)`,
);
summarize(page, result.from);
check(remounts === 0, "a connectivity blip does not remount a live display");
check(
firstOfflineAt === undefined,
"a connectivity blip does not show an offline state on a live display",
);
await page.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "session-blip") {
// One transient failure of Signage's viewer-session request at start-up, as a kiosk booting
// before its network settles, or an API restart, would produce. Everything else reaches the
// server.
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const page = await openPage(kiosk, undefined, "session-blip");
let blocked = 0;
kiosk.on((message) => {
if (message.method === "Fetch.requestPaused") {
const { requestId, request } = message.params;
if (blocked === 0 && pathOf(request.url).endsWith("/viewers/session")) {
blocked += 1;
page
.send("Fetch.failRequest", { errorReason: "ConnectionRefused", requestId })
.catch((error) => note(`interception reply failed: ${error.message}`));
} else {
page
.send("Fetch.continueRequest", { requestId })
.catch((error) => note(`interception reply failed: ${error.message}`));
}
}
});
await page.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/v1/modules/*" }] });
await page.navigate(`${apiOrigin}/display/${moduleId}`);
await page.waitFor(
`(${find("display-module-container")}?.childElementCount ?? 0) > 0`,
"the module to mount",
);
note(`viewer-session requests failed on purpose: ${blocked}`);
const recovered = await page.waitFor(
`${find("signage-stage")}?.dataset.offline === "false"`,
"recovery from one failed viewer-session request",
90_000,
);
summarize(page, stepStartedAt);
check(
recovered,
`one failed viewer-session request does not leave the display offline (${JSON.stringify(await displayState(page))})`,
);
await page.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "stream-failure") {
// The first realtime connection is refused with a 503, as an API mid-restart or a proxy with no
// upstream answers. A non-200 ends an EventSource for good; the question is whether anything
// re-establishes realtime without a reload.
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const page = await openPage(kiosk, undefined, "stream-failure");
let refused = 0;
kiosk.on((message) => {
if (message.method === "Fetch.requestPaused") {
const { requestId } = message.params;
if (refused === 0) {
refused += 1;
page
.send("Fetch.fulfillRequest", {
body: Buffer.from('{"code":"REALTIME_UNAVAILABLE"}').toString("base64"),
requestId,
responseCode: 503,
responseHeaders: [
{ name: "content-type", value: "application/json" },
{ name: "cache-control", value: "no-store" },
],
})
.catch((error) => note(`interception reply failed: ${error.message}`));
} else {
page
.send("Fetch.continueRequest", { requestId })
.catch((error) => note(`interception reply failed: ${error.message}`));
}
}
});
await page.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/v1/module-realtime/*" }] });
await page.navigate(`${apiOrigin}/display/${moduleId}`);
await page.waitFor(
`(${find("display-module-container")}?.childElementCount ?? 0) > 0`,
"the module to mount",
);
note(`realtime connections refused on purpose: ${refused}`);
const result = await watch([page], 90_000, {
onSample: () => page.streams.some((stream) => stream.status === 200),
});
printTally(result);
summarize(page, result.from);
check(
page.streams.some((stream) => stream.status === 200),
"after one refused realtime connection, realtime is re-established without a reload",
);
await page.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "caching-probe") {
// Whether a display on the API origin can store an image from the storage origin in Cache
// Storage, which is what Signage's offline media relies on. Reports only statuses, response
// types, and whether CORS headers were present — never a URL.
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const groups = await owner.page.api("GET", "/api/v1/modules/signage/channel-groups");
const channelId = groups.body?.groups?.[0]?.channels?.[0]?.id;
const kiosk = await connect(kioskAddress, "kiosk");
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "probe");
check(await waitLive(page, "the display"), "the display is live");
progress("letting the display prefetch its slides (15s)");
await sleep(15_000);
const probe = await page.evaluate(
`(async () => {
const result = {};
const state = await fetch(${JSON.stringify(`${apiOrigin}/api/v1/modules/signage/channels/${channelId}/display-state`)}, { credentials: "include", signal: AbortSignal.timeout(${requestTimeoutMs}) }).then((r) => r.json());
const url = (state.selections || []).map((s) => s.media && s.media.display && s.media.display.url).find(Boolean);
result.selections = (state.selections || []).length;
result.haveUrl = Boolean(url);
if (!url) return result;
result.sameOrigin = new URL(url).origin === location.origin;
try {
const response = await fetch(url, { signal: AbortSignal.timeout(${requestTimeoutMs}) });
result.cors = { status: response.status, type: response.type, allowOrigin: response.headers.get("access-control-allow-origin") === null ? "absent" : (response.headers.get("access-control-allow-origin") === location.origin || response.headers.get("access-control-allow-origin") === "*" ? "matches" : "other") };
try {
const cache = await caches.open("display-repro-probe");
await cache.put("/display-repro-probe/cors", response.clone());
result.cors.cachePut = "stored";
} catch (error) { result.cors.cachePut = "refused: " + error.name; }
} catch (error) { result.cors = { error: error.name }; }
try {
const response = await fetch(url, { mode: "no-cors", signal: AbortSignal.timeout(${requestTimeoutMs}) });
result.noCors = { status: response.status, type: response.type, ok: response.ok };
try {
const cache = await caches.open("display-repro-probe");
await cache.put("/display-repro-probe/opaque", response.clone());
result.noCors.cachePut = "stored";
} catch (error) { result.noCors.cachePut = "refused: " + error.name; }
} catch (error) { result.noCors = { error: error.name }; }
result.imgPlain = await new Promise((resolve) => { const i = new Image(); i.onload = () => resolve("loaded"); i.onerror = () => resolve("error"); i.src = url; });
result.imgCors = await new Promise((resolve) => { const i = new Image(); i.crossOrigin = "anonymous"; i.onload = () => resolve("loaded"); i.onerror = () => resolve("error"); i.src = url + (url.includes("?") ? "&" : "?") + "cors-probe=1"; });
await caches.delete("display-repro-probe");
result.signageCachedEntries = await caches.open("ekklesia-signage-media-v1").then((c) => c.keys()).then((k) => k.length).catch(() => "unavailable");
return result;
})()`,
"probing cross-origin image caching",
);
note(`caching probe: ${JSON.stringify(probe)}`);
summarize(page, stepStartedAt);
check(probe.signageCachedEntries > 0, "Signage has stored slide images in Cache Storage");
await page.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "stream-recycle") {
// One page, past the five-minute realtime connection age, with every stream's open and end
// time in UTC so they can be laid against the proxy's own log.
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "recycle");
check(await waitLive(page, "the display"), "the display is live");
const result = await watch([page], recycleWatchMs, { viewerOwner: owner.page });
printTally(result);
summarize(page, result.from);
note(`connectivity events ${JSON.stringify(await connectivityEvents(page))}`);
const blobs = await blobCounts(page);
const exhausted = page.stateFetches.filter((fetch) =>
fetch.outcome.includes("INSUFFICIENT_RESOURCES"),
);
note(
`object URLs: created ${blobs.created}, revoked ${blobs.revoked}, live ${blobs.created - blobs.revoked}; channel-state fetches refused for insufficient resources: ${exhausted.length}`,
);
check(
blobs.created - blobs.revoked <= MAX_LIVE_OBJECT_URLS,
`live object URLs stay bounded (at most ${MAX_LIVE_OBJECT_URLS}; ${blobs.created - blobs.revoked} live)`,
);
check(
result.tally.get("recycle").offline === 0,
`the display stayed live for the whole ${recycleWatchMs / 1000}s watch${recycleWatchMs > 300_000 ? ", across a realtime recycle" : ""}`,
);
check(page.sessionRequests.length === 1, "the display was never remounted during the watch");
await page.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "revoke") {
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "revoked");
check(
await waitLive(page, "the display before revocation"),
"the display is live before revocation",
);
const devices = (await coreDevices(owner.page)).filter((device) => !device.revoked);
for (const device of devices) {
await owner.page.api("POST", `/api/v1/devices/${device.id}/revoke`);
}
note(`revoked ${devices.length} device(s) at ${clock()}; watching the open page`);
const result = await watch([page], watchMs, { onSample: (_page, state) => state.pairing });
printTally(result);
summarize(page, result.from);
check(
result.stopped,
`a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`,
);
await page.close();
kiosk.close();
await finishOwner(owner);
} else {
throw new Error(
"usage: deploy-smoke-display.mjs pair|restart|two-pages|connectivity-blip|session-blip|stream-failure|caching-probe|stream-recycle|revoke",
);
}
}
try {
await run();
} catch (error) {
if (error instanceof HangError) {
console.log(`FAIL ${mode}: ${error.message}; also in flight: ${describeInFlight()}`);
} else {
console.log(`FAIL ${mode}: ${error instanceof Error ? error.message : "an unexpected error"}`);
}
process.exit(2);
}
if (failures > 0) {
console.error(`${failures} check(s) failed`);
process.exit(1);
}
process.exit(0);
+589
View File
@@ -0,0 +1,589 @@
#!/usr/bin/env bash
# End-to-end smoke test of the reference deployment (ADR 0044): deploy/compose.yaml with
# deploy/compose.proxy.yaml, a platform-only image, and a disposable PostgreSQL and SeaweedFS that
# exist only for this run. CI runs it; it is safe to run on a workstation.
#
# bash scripts/smoke-deploy.sh builds the image first
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
#
# Either topology (ADR 0045). `same-origin` (the default) serves the Dashboard, the API, and displays
# from one hostname with Caddyfile.example and a platform-only image. `split` serves the Dashboard on
# its own hostname with Caddyfile.split.example, builds an image with the repository's module
# template installed as a test-only module (so a module UI, its API routes, and a display exist), and
# then drives a real headless Chromium through the Dashboard, the module, the display, and sign-out.
# With EKKLESIA_SMOKE_IMAGE, the given image is used as is in either topology; a split run then
# exercises whichever of its modules publishes UI and a display.
#
# Opt-in, split topology only: EKKLESIA_SMOKE_DISPLAY_DEVICE=1 adds the display-device scenarios in
# scripts/lib/deploy-smoke-display.mjs (pairing a kiosk browser with a persistent profile, a clean
# restart, two pages, injected network failures, revocation), each with its own time budget.
# EKKLESIA_SMOKE_DISPLAY_SCENARIOS selects a subset; EKKLESIA_SMOKE_WATCH_MS and
# EKKLESIA_SMOKE_RECYCLE_WATCH_MS change how long they watch. Several scenarios expect Signage.
#
# TEST-ONLY: the proxy runs with Caddy's internal CA (`local_certs`) instead of ACME, because a test
# run has no public hostname. Never use that option in an installation.
#
# Follows the documented first boot: TLS material, secrets, data services, an explicit migration
# apply against this run's own fresh database, then the API and proxy. Everything it starts, and
# every file it writes, is removed on exit.
#
# Bind mounts are resolved by the Docker daemon, on its host. When this script runs inside a CI job
# container that drives the host's Docker, its own files are not at those paths, so every file a
# container mounts is staged under an identical path in the Docker host's /tmp first.
set -Eeuo pipefail
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repository_root"
# shellcheck source=scripts/lib/compose-diagnostics.sh
source "$repository_root/scripts/lib/compose-diagnostics.sh"
# shellcheck source=scripts/lib/ci-project.sh
source "$repository_root/scripts/lib/ci-project.sh"
for command in docker openssl awk; do
command -v "$command" >/dev/null || {
echo "smoke-deploy: $command is required." >&2
exit 1
}
done
docker compose version >/dev/null 2>&1 || {
echo "smoke-deploy: Docker Compose is required." >&2
exit 1
}
# The pinned PostgreSQL image doubles as a root shell for file ownership, so the run needs neither
# root nor sudo on the host.
postgres_image="postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15"
# The split topology's real browser: headless Chromium, pinned by its multi-architecture index digest.
browser_image="chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04"
topology="${EKKLESIA_SMOKE_TOPOLOGY:-same-origin}"
case "$topology" in
same-origin | split) ;;
*)
echo "smoke-deploy: EKKLESIA_SMOKE_TOPOLOGY must be same-origin or split." >&2
exit 1
;;
esac
run_id="$(openssl rand -hex 4)"
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries
# the same name in EKKLESIA_CI_LABEL.
project="$(ci_project_name "deploy-${topology}")"
work="$(mktemp -d)"
image="${EKKLESIA_SMOKE_IMAGE:-}"
built_image=""
# `hostname` is always the API's (and the display host's); the Dashboard has its own only when split.
if [[ "$topology" == split ]]; then
hostname="api.smoke.test"
dashboard_hostname="dash.smoke.test"
caddyfile_example="deploy/Caddyfile.split.example"
else
hostname="ekklesia.smoke.test"
dashboard_hostname=""
caddyfile_example="deploy/Caddyfile.example"
fi
dashboard_origin="https://${dashboard_hostname:-$hostname}"
storage_hostname="storage.smoke.test"
browser_container=""
# choose_edge_network: a per-run edge subnet that no network on this Docker daemon already uses, so
# concurrent runs sharing a runner's daemon do not collide. Two runs can still pick the same free
# subnet at the same moment; the first `up` below detects that and chooses again. Fixed addresses come
# from the upper half, outside Docker's dynamic range, exactly as documented.
choose_edge_network() {
local in_use candidate attempt
in_use="$(
docker network ls --quiet |
xargs -r docker network inspect --format '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' 2>/dev/null |
sed -n 's#^10\.249\.\([0-9]*\)\.0/24$#\1#p'
)" || in_use=""
for attempt in $(seq 1 100); do
candidate=$((RANDOM % 200 + 20))
if ! grep -qx "$candidate" <<<"$in_use"; then
octet="$candidate"
edge_subnet="10.249.${octet}.0/24"
edge_range="10.249.${octet}.0/25"
proxy_address="10.249.${octet}.254"
tunnel_address="10.249.${octet}.253"
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
session_client_address="10.249.${octet}.252"
# The headless browser's fixed address, likewise (split topology only).
browser_address="10.249.${octet}.251"
return 0
fi
done
echo "smoke-deploy: no free 10.249.x.0/24 edge subnet on this Docker daemon." >&2
exit 1
}
choose_edge_network
# daemon_work: where the Docker daemon sees this run's mounted files. The same directory as `work`
# on a workstation; a staged copy on the Docker host when this script runs in a job container.
if [[ -f /.dockerenv ]]; then
daemon_work="/tmp/${project}"
docker run --rm --entrypoint mkdir -v /tmp:/host-tmp "$postgres_image" -p "/host-tmp/${project}"
# stage <path relative to work>: copy it to the same relative path under daemon_work, keeping
# numeric ownership and modes.
stage() {
tar -C "$work" -cf - "$1" |
docker run --rm -i --entrypoint tar -v "$daemon_work:/w" "$postgres_image" -xpf - -C /w
}
else
daemon_work="$work"
stage() { :; }
fi
compose=(
docker compose
--project-name "$project"
--project-directory "$daemon_work/deploy"
--env-file "$work/compose.env"
--file deploy/compose.yaml
--file deploy/compose.proxy.yaml
)
cleanup() {
local status=$?
trap - EXIT INT TERM
if [[ -n "$browser_container" ]]; then
docker rm --force "$browser_container" >/dev/null 2>&1 || true
fi
if [[ -n "${kiosk_container:-}" ]]; then
docker ps --all --quiet --filter "name=${project}-display-" | xargs -r docker rm --force \
>/dev/null 2>&1 || true
docker rm --force "$kiosk_container" >/dev/null 2>&1 || true
docker volume rm --force "$kiosk_volume" >/dev/null 2>&1 || true
fi
if [[ -f "$work/compose.env" ]]; then
"${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1
fi
# Some files are owned by the PostgreSQL and SeaweedFS uids by now; remove them as root, in the
# same image that set that ownership. In a job container the daemon cannot see `work` (and the
# job itself runs as root), so only the staged copy on the Docker host needs this.
if [[ "$daemon_work" == "$work" ]]; then
docker run --rm --entrypoint rm -v "$work:/work" "$postgres_image" -rf /work/tls /work/env \
>/dev/null 2>&1 || true
else
docker run --rm --entrypoint rm -v /tmp:/host-tmp "$postgres_image" -rf "/host-tmp/${project}" \
>/dev/null 2>&1 || true
fi
rm -rf "$work" || true
if [[ -n "$built_image" ]]; then
docker image rm --force "$built_image" >/dev/null 2>&1 || true
fi
exit "$status"
}
trap cleanup EXIT INT TERM
step() {
echo "==> $*"
}
fail() {
echo "smoke-deploy: $*" >&2
exit 1
}
if [[ -z "$image" && "$topology" == same-origin ]]; then
step "Building the platform-only image"
built_image="ekklesia-deploy-smoke:${run_id}"
DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" \
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
. >/dev/null
image="$built_image"
elif [[ -z "$image" ]]; then
# TEST-ONLY: the repository's module template, installed as the module `example`, in a copy of the
# working tree. Its lockfile importer moves with it; the depth is the same, so every relative link
# in that importer stays correct and the install stays frozen.
step "Building an image with the module template installed as a test-only module"
command -v git >/dev/null || fail "git is required to build the split topology's test image."
context="$work/build-context"
mkdir -p "$context"
git ls-files -z --cached --others --exclude-standard |
tar --null --files-from=- --create --file=- |
tar -C "$context" -xf -
mkdir -p "$context/modules"
mv "$context/templates/module" "$context/modules/example"
[[ "$(grep -c '^ templates/module:$' "$context/pnpm-lock.yaml")" == 1 ]] ||
fail "the lockfile has no single templates/module importer to move."
sed -i 's|^ templates/module:$| modules/example:|' "$context/pnpm-lock.yaml"
built_image="ekklesia-deploy-smoke:${run_id}"
DOCKER_BUILDKIT=1 docker build --quiet --file "$context/deploy/Dockerfile" --tag "$built_image" \
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
"$context" >/dev/null || fail "the test-module image did not build."
rm -rf "$context"
image="$built_image"
fi
# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here.
cp -R deploy "$work/deploy"
stage deploy
step "Creating the installation-private CA and server certificates"
init_status=0
sh deploy/scripts/init-tls.sh "$work/tls" >/dev/null 2>&1 || init_status=$?
if ((init_status != 0 && init_status != 77)); then
fail "init-tls.sh failed to create TLS material."
fi
stage tls
# The root-only ownership pass, exactly as an operator would re-run the script with root.
ownership_output="$(
docker run --rm --entrypoint sh -v "$daemon_work/tls:/tls" \
-v "$daemon_work/deploy/scripts:/scripts:ro" "$postgres_image" /scripts/init-tls.sh /tls
)" || fail "init-tls.sh could not apply ownership."
database_ca_line="$(grep '^EKKLESIA_DATABASE_CA=' <<<"$ownership_output")" ||
fail "init-tls.sh did not print the EKKLESIA_DATABASE_CA line."
# A second run must create nothing and still succeed.
rerun_output="$(
docker run --rm --entrypoint sh -v "$daemon_work/tls:/tls" \
-v "$daemon_work/deploy/scripts:/scripts:ro" "$postgres_image" /scripts/init-tls.sh /tls
)" || fail "init-tls.sh failed on a second run."
grep -q 'nothing to create' <<<"$rerun_output" || fail "init-tls.sh is not idempotent."
step "Writing env files from the examples, with freshly generated secrets"
secrets="$(sh deploy/scripts/gen-secrets.sh)"
secret() {
awk -F= -v key="$1" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' <<<"$secrets"
}
postgres_password="$(secret POSTGRES_PASSWORD)"
s3_access_key="$(secret S3_ACCESS_KEY)"
s3_secret_key="$(secret S3_SECRET_KEY)"
identity_secret="$(secret EKKLESIA_IDENTITY_SECRET)"
# Masked in any diagnostics below, along with anything else that looks like a credential.
COMPOSE_DIAGNOSTICS_SECRETS=("$postgres_password" "$s3_access_key" "$s3_secret_key" "$identity_secret")
mkdir -p "$work/env"
# set_env <file> <key> <value>: replaces an uncommented KEY= line, keeping the value byte-exact.
set_env() {
KEY="$2" VALUE="$3" awk '
BEGIN { found = 0 }
index($0, ENVIRON["KEY"] "=") == 1 { print ENVIRON["KEY"] "=" ENVIRON["VALUE"]; found = 1; next }
{ print }
END { if (!found) print ENVIRON["KEY"] "=" ENVIRON["VALUE"] }
' "$1" >"$1.next"
mv "$1.next" "$1"
}
for name in ekklesia postgres object-storage; do
cp "deploy/env/${name}.env.example" "$work/env/${name}.env"
done
ekklesia_env="$work/env/ekklesia.env"
set_env "$ekklesia_env" DATABASE_URL "postgresql://ekklesia:${postgres_password}@postgres:5432/ekklesia"
set_env "$ekklesia_env" EKKLESIA_DATABASE_CA "${database_ca_line#EKKLESIA_DATABASE_CA=}"
set_env "$ekklesia_env" EKKLESIA_IDENTITY_BASE_URL "https://${hostname}"
set_env "$ekklesia_env" EKKLESIA_IDENTITY_TRUSTED_ORIGINS "$dashboard_origin"
if [[ "$topology" == split ]]; then
set_env "$ekklesia_env" EKKLESIA_DASHBOARD_ORIGIN "$dashboard_origin"
fi
set_env "$ekklesia_env" EKKLESIA_IDENTITY_SECRET "$identity_secret"
set_env "$ekklesia_env" S3_DELIVERY_ENDPOINT "https://${storage_hostname}"
set_env "$ekklesia_env" S3_ACCESS_KEY "$s3_access_key"
set_env "$ekklesia_env" S3_SECRET_KEY "$s3_secret_key"
set_env "$work/env/postgres.env" POSTGRES_PASSWORD "$postgres_password"
set_env "$work/env/object-storage.env" AWS_ACCESS_KEY_ID "$s3_access_key"
set_env "$work/env/object-storage.env" AWS_SECRET_ACCESS_KEY "$s3_secret_key"
chmod 600 "$work"/env/*.env
# TEST-ONLY: Caddy's internal CA instead of ACME. Inserted as the first global option.
awk '!done && $0 == "{" { print; print "\tlocal_certs"; done = 1; next } { print }' \
"$caddyfile_example" >"$work/Caddyfile"
grep -q $'^\tlocal_certs$' "$work/Caddyfile" || fail "could not enable local_certs for the test proxy."
stage Caddyfile
cat >"$work/compose.env" <<EOF
EKKLESIA_IMAGE=${image}
EKKLESIA_ENV_DIR=${work}/env
EKKLESIA_TLS_DIR=${daemon_work}/tls
EKKLESIA_HOSTNAME=${hostname}
EKKLESIA_DASHBOARD_HOSTNAME=${dashboard_hostname}
EKKLESIA_STORAGE_HOSTNAME=${storage_hostname}
EKKLESIA_ACME_EMAIL=[email protected]
EKKLESIA_CADDYFILE=${daemon_work}/Caddyfile
EKKLESIA_PUBLISH_ADDRESS=127.0.0.1
EKKLESIA_HTTP_PORT=0
EKKLESIA_HTTPS_PORT=0
EKKLESIA_EDGE_SUBNET=${edge_subnet}
EKKLESIA_EDGE_DYNAMIC_RANGE=${edge_range}
EKKLESIA_PROXY_ADDRESS=${proxy_address}
EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
EOF
step "Starting PostgreSQL and object storage"
# This `up` creates the edge network. If another run claimed the same subnet since it was chosen,
# Docker refuses with an address-pool overlap: choose another and start again, a bounded number of
# times. Any other failure is reported with the project's containers and logs.
for attempt in 1 2 3 4 5; do
if up_output="$(
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage 2>&1
)"; then
break
fi
if [[ "$up_output" == *"overlap"* ]] && ((attempt < 5)); then
echo " edge subnet ${edge_subnet} was taken by a concurrent run; choosing another"
"${compose[@]}" down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || true
choose_edge_network
set_env "$work/compose.env" EKKLESIA_EDGE_SUBNET "$edge_subnet"
set_env "$work/compose.env" EKKLESIA_EDGE_DYNAMIC_RANGE "$edge_range"
set_env "$work/compose.env" EKKLESIA_PROXY_ADDRESS "$proxy_address"
set_env "$work/compose.env" EKKLESIA_TUNNEL_ADDRESS "$tunnel_address"
continue
fi
compose_report_not_ready "$up_output" "${compose[@]}"
fail "PostgreSQL and object storage did not become healthy."
done
step "Planning, then explicitly applying, migrations against this run's fresh database"
"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "migrate (plan) failed."
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "migrate (apply) failed."
step "Starting the API and the proxy"
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 2>&1)"; then
compose_report_not_ready "$up_output" "${compose[@]}"
fail "the API and proxy did not become healthy."
fi
# Caddy creates its internal CA on first start. The test clients trust it, plus the installation CA
# for their direct connection to object-storage.
for _ in $(seq 1 30); do
if "${compose[@]}" exec -T proxy cat /data/caddy/pki/authorities/local/root.crt \
>"$work/caddy-root.crt" 2>/dev/null && [[ -s "$work/caddy-root.crt" ]]; then
break
fi
sleep 1
done
[[ -s "$work/caddy-root.crt" ]] || fail "the test proxy's internal CA did not appear."
cat "$work/caddy-root.crt" "$work/tls/ca.crt" >"$work/client-ca.pem"
cp scripts/lib/deploy-smoke-client.mjs "$work/client.mjs"
stage client-ca.pem
stage client.mjs
edge_network="${project}_edge"
host_mappings=(--add-host "${hostname}:${proxy_address}" --add-host "${storage_hostname}:${proxy_address}")
if [[ -n "$dashboard_hostname" ]]; then
host_mappings+=(--add-host "${dashboard_hostname}:${proxy_address}")
fi
client() {
local mode="$1"
shift
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
--label "${EKKLESIA_CI_LABEL}=${project}" \
--network "$edge_network" "$@" "${host_mappings[@]}" \
-e NODE_EXTRA_CA_CERTS=/smoke/client-ca.pem -e SMOKE_HOSTNAME="$hostname" \
-e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
-e SMOKE_STORAGE_HOSTNAME="$storage_hostname" \
-e S3_ACCESS_KEY="$s3_access_key" -e S3_SECRET_KEY="$s3_secret_key" \
-v "$daemon_work/client.mjs:/smoke/client.mjs:ro" \
-v "$daemon_work/client-ca.pem:/smoke/client-ca.pem:ro" \
--entrypoint node "$image" /smoke/client.mjs "$mode"
}
step "Checking the API, Dashboard, storage delivery, and client-address chain through the proxy"
client edge || fail "edge checks failed."
step "Checking the tunnel connector's client-address path from its fixed address"
client tunnel --ip "$tunnel_address" || fail "tunnel checks failed."
step "Creating the initial owner with the image's own CLI, then signing in and out through the proxy"
owner_email="[email protected]"
owner_password="$(openssl rand -hex 16)"
printf '%s' "$owner_password" |
"${compose[@]}" run --rm --no-deps -T migrate node core/dist/cli/create-initial-owner.js \
--email "$owner_email" >/dev/null || fail "creating the initial owner failed."
echo "ok the initial owner was created from the image (password on standard input)"
client session --ip "$session_client_address" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" ||
fail "session checks failed."
if [[ "$topology" == split ]]; then
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
# which signs every certificate the test proxy serves: no CA is installed anywhere, and nothing but
# this run's certificates is accepted.
"${compose[@]}" exec -T proxy cat /data/caddy/pki/authorities/local/intermediate.crt \
>"$work/caddy-intermediate.crt" || fail "the test proxy's intermediate CA is unavailable."
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
browser_container="${project}-browser"
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
"$browser_image" \
"--ignore-certificate-errors-spki-list=${spki}" \
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
stage browser.mjs
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
--label "${EKKLESIA_CI_LABEL}=${project}" \
--network "$edge_network" \
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
# A kiosk: a second browser whose profile directory is a volume, so it outlives the container
# exactly as a kiosk's --user-data-dir outlives a browser restart.
kiosk_container="${project}-kiosk"
kiosk_volume="${project}-kiosk-profile"
kiosk_address="10.249.${octet}.250"
docker volume create --label "${EKKLESIA_CI_LABEL}=${project}" "$kiosk_volume" >/dev/null
start_kiosk() {
docker run --detach --rm --name "$kiosk_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
--network "$edge_network" --ip "$kiosk_address" "${host_mappings[@]}" \
-v "${kiosk_volume}:/profile" \
"$browser_image" \
"--ignore-certificate-errors-spki-list=${spki}" \
--user-data-dir=/profile >/dev/null || fail "the kiosk browser did not start."
}
wait_kiosk_exit() {
for _ in $(seq 1 60); do
docker inspect "$kiosk_container" >/dev/null 2>&1 || return 0
sleep 0.5
done
fail "the kiosk browser did not exit after a clean close."
}
cp scripts/lib/deploy-smoke-display.mjs "$work/display.mjs"
stage display.mjs
# display_driver <budget seconds> <step>: one scenario with a hard budget. The driver enforces it
# itself and names whatever call was still waiting; `timeout` is the backstop if it cannot.
display_driver() {
local budget="$1" step_name="$2" driver="${project}-display-${2}"
timeout --signal=TERM "$((budget + 30))" \
docker run --rm --name "$driver" --read-only --cap-drop ALL --security-opt no-new-privileges \
--network "$edge_network" \
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
-e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \
-e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
-e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
-v "$daemon_work/display.mjs:/smoke/display.mjs:ro" \
--entrypoint node "$image" /smoke/display.mjs "$step_name"
local status=$?
if ((status == 124)); then
echo "FAIL ${step_name}: the driver did not stop within its budget plus 30s; removed it"
docker rm --force "$driver" >/dev/null 2>&1 || true
fi
return "$status"
}
display_status=0
step "Pairing a kiosk display, then closing its browser cleanly"
start_kiosk
display_driver 180 pair || display_status=1
wait_kiosk_exit
step "Restarting the kiosk browser on the same profile"
start_kiosk
display_driver 120 restart || display_status=1
for scenario in ${EKKLESIA_SMOKE_DISPLAY_SCENARIOS:-two-pages connectivity-blip session-blip stream-failure caching-probe stream-recycle revoke}; do
case "$scenario" in
two-pages) budget=240 ;;
stream-recycle) budget=420 ;;
revoke) budget=210 ;;
*) budget=150 ;;
esac
step "Display scenario: ${scenario} (budget ${budget}s)"
display_driver "$budget" "$scenario" || display_status=1
done
step "Proxy: streams aborted with an incomplete response (UTC time, path only)"
# Captured, never printed: only each line's time and path (no query, so no signature) leave it.
proxy_logs="$("${compose[@]}" logs --no-color proxy 2>/dev/null)" || proxy_logs=""
grep -o '{.*"aborting with incomplete response".*}' <<<"$proxy_logs" |
sed -E 's/.*"ts":([0-9]+)[^,]*,.*"uri":"([^"?]*)[^"]*".*/\1 \2/' |
while read -r ts uri; do echo " $(date -u -d "@${ts}" +%T) ${uri}"; done || true
((display_status == 0)) || fail "display-device checks failed."
fi
fi
step "Proving the API's database connection is certificate-verified TLS"
api_container="$("${compose[@]}" ps -q api)"
api_data_address="$(
docker inspect --format "{{(index .NetworkSettings.Networks \"${project}_data\").IPAddress}}" \
"$api_container"
)"
[[ -n "$api_data_address" ]] || fail "could not find the API's database-network address."
psql_as_superuser() {
"${compose[@]}" exec -T postgres psql -U ekklesia -d ekklesia -Atc "$1"
}
tls_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND s.ssl AND s.version = 'TLSv1.3'")"
plain_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND NOT s.ssl")"
((tls_connections > 0)) || fail "the API holds no TLS 1.3 database connection."
((plain_connections == 0)) || fail "the API holds a database connection without TLS."
echo "ok the API's database connections are TLS 1.3 (${tls_connections}), none plaintext"
# PostgreSQL itself refuses a network connection without TLS.
if "${compose[@]}" exec -T -e PGPASSWORD="$postgres_password" postgres \
psql "host=postgres user=ekklesia dbname=ekklesia sslmode=disable" -Atc 'SELECT 1' >/dev/null 2>&1; then
fail "PostgreSQL accepted a plaintext network connection."
fi
echo "ok PostgreSQL refuses a plaintext network connection"
# The server cannot see whether a client verified it, so verification is proven by refusal: the
# same command fails against a CA that did not issue the certificate, and against a host the
# certificate does not name.
openssl ecparam -name prime256v1 -genkey -noout -out "$work/other-ca.key" 2>/dev/null
openssl req -x509 -new -sha256 -days 1 -key "$work/other-ca.key" -out "$work/other-ca.crt" \
-subj "/CN=Unrelated CA" 2>/dev/null
# expect_connection_refused <description> <compose run arguments...>: the command must fail, and fail
# at the connection, not on a configuration error that would prove nothing about verification.
expect_connection_refused() {
local description="$1"
shift
local output
if output="$("${compose[@]}" run --rm --no-deps "$@" 2>&1)"; then
fail "the database connection accepted ${description}."
fi
grep -q 'MIGRATION_CONNECTION_FAILED' <<<"$output" ||
fail "rejecting ${description} failed for a reason other than the connection."
}
expect_connection_refused "a certificate from a CA it was not given" \
-e EKKLESIA_DATABASE_CA="$(cat "$work/other-ca.crt")" migrate
echo "ok the database connection rejects a certificate its CA did not issue"
postgres_address="$(
docker inspect --format "{{(index .NetworkSettings.Networks \"${project}_data\").IPAddress}}" \
"$("${compose[@]}" ps -q postgres)"
)"
expect_connection_refused "a certificate for a different host name" \
-e DATABASE_URL="postgresql://ekklesia:${postgres_password}@${postgres_address}:5432/ekklesia" migrate
echo "ok the database connection rejects a certificate that does not name the host"
"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "the control connection failed."
echo "ok the same connection succeeds with the installation CA and the name postgres"
step "Running Doctor from the image"
doctor_output="$("${compose[@]}" run --rm --no-deps migrate node core/dist/cli/doctor.js 2>&1)" ||
fail "Doctor reported the installation not ready."
grep -q 'Result: READY' <<<"$doctor_output" || fail "Doctor did not report READY."
grep -q 'TLS required with certificate and hostname verification (explicit private CA supplied)' \
<<<"$doctor_output" || fail "Doctor did not report verify-full with the private CA."
grep -q '✓ Object storage reachable' <<<"$doctor_output" ||
fail "Doctor could not reach object storage over HTTPS."
# Doctor runs as `migrate`; it must check the client-address configuration the API runs with, which
# behind the proxy is trusted-proxy mode, not the direct mode an unconfigured process reports.
grep -q 'Trusted-proxy client-address mode' <<<"$doctor_output" ||
fail "Doctor did not check the API's trusted-proxy client-address configuration."
for value in "$postgres_password" "$s3_secret_key" "$identity_secret"; do
if grep -qF -- "$value" <<<"$doctor_output"; then
fail "Doctor output contains a secret."
fi
done
echo "ok Doctor: READY, verify-full with the private CA, storage reachable, the API's client-address mode, no secret printed"
api_logs="$("${compose[@]}" logs --no-color api 2>&1)"
for value in "$postgres_password" "$s3_secret_key" "$identity_secret" "$owner_password"; do
if grep -qF -- "$value" <<<"$api_logs"; then
fail "the API's logs contain a secret."
fi
done
restarts="$(docker inspect --format '{{.RestartCount}}' "$api_container")"
((restarts == 0)) || fail "the API restarted ${restarts} time(s)."
echo "ok the API logged no secret and never restarted"
echo "Reference deployment smoke test passed."
+19 -6
View File
@@ -7,6 +7,10 @@ cd "$repository_root"
# shellcheck source=scripts/lib/compose-job-network.sh
source "$repository_root/scripts/lib/compose-job-network.sh"
# shellcheck source=scripts/lib/compose-diagnostics.sh
source "$repository_root/scripts/lib/compose-diagnostics.sh"
# shellcheck source=scripts/lib/ci-project.sh
source "$repository_root/scripts/lib/ci-project.sh"
command -v docker >/dev/null || {
echo "Docker is required for the infrastructure smoke test." >&2
@@ -28,9 +32,9 @@ if [[ "$curl_help" != *"--aws-sigv4"* ]]; then
exit 1
fi
raw_project="ekklesia-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-$$"
project="${raw_project//[^a-zA-Z0-9_-]/-}"
project="${project,,}"
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh).
project="$(ci_project_name infrastructure)"
temporary_directory="$(mktemp -d)"
payload_file="$temporary_directory/payload.txt"
download_file="$temporary_directory/download.txt"
@@ -46,6 +50,8 @@ export S3_SECRET_KEY="ekklesia-smoke-secret-key"
export S3_BUCKET="ekklesia-smoke"
export S3_REGION="us-east-1"
export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"
COMPOSE_DIAGNOSTICS_SECRETS=("$POSTGRES_PASSWORD" "$S3_ACCESS_KEY" "$S3_SECRET_KEY")
up_output=""
compose=(
docker compose
@@ -63,7 +69,7 @@ cleanup() {
local cleanup_output
trap - EXIT INT TERM
if ((exit_status != 0)); then
"${compose[@]}" logs --no-color >&2 || true
compose_report_not_ready "$up_output" "${compose[@]}" || true
fi
if ! compose_job_network_disconnect; then
echo "Infrastructure smoke network cleanup failed." >&2
@@ -88,8 +94,15 @@ trap 'exit 130' INT
trap 'exit 143' TERM
"${compose[@]}" config --quiet
"${compose[@]}" up --detach --wait --wait-timeout 120
"${compose[@]}" up --detach --wait --wait-timeout 120
# Started twice: the second `up` proves the project is idempotent. A failure's own output is kept for
# the cleanup's diagnostics, which also print every container and the tail of its logs.
for _ in 1 2; do
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 120 2>&1)"; then
echo "Disposable PostgreSQL and object storage did not become ready." >&2
exit 1
fi
done
up_output=""
database_result="$(
"${compose[@]}" exec -T \
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# Proves scripts/ci-sweep.sh against real Docker resources: what it removes, and above all what it
# leaves alone. CI runs it before trusting the sweep with a shared runner's daemon.
#
# bash scripts/test-ci-sweep.sh
#
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, so
# it can never touch a real stack; and it removes all of them on exit, whatever happens.
set -Eeuo pipefail
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repository_root"
image="$(sed -n 's/^[[:space:]]*image: \(postgres:[^[:space:]]*\)$/\1/p' deploy/compose.yaml | head -1)"
[[ -n "$image" ]] || {
echo "test-ci-sweep: no pinned PostgreSQL image in deploy/compose.yaml." >&2
exit 1
}
docker image inspect "$image" >/dev/null 2>&1 || docker pull --quiet "$image" >/dev/null
nonce="$(openssl rand -hex 4)"
compose_label="com.docker.compose.project"
ci_label="org.riverofglorymn.ekklesia.ci.project"
failures=0
created=()
check() {
if eval "$1"; then
echo "ok $2"
else
echo "FAIL $2"
failures=$((failures + 1))
fi
}
# project_exists <project>: any container, network, volume, or image still carries its label.
project_exists() {
local project="$1"
[[ -n "$(
docker ps --all --quiet --filter "label=${compose_label}=${project}"
docker ps --all --quiet --filter "label=${ci_label}=${project}"
docker network ls --quiet --filter "label=${compose_label}=${project}"
docker volume ls --quiet --filter "label=${compose_label}=${project}"
docker image ls --quiet --filter "label=${ci_label}=${project}"
)" ]]
}
staging_exists() {
docker run --rm --entrypoint test -v /tmp:/host-tmp "$image" -d "/host-tmp/$1"
}
# make_stack <project>: what a CI script leaves behind when it is killed: a Compose-labeled network,
# volume, and running container; a CI-labeled container and image; and a staging directory on the
# Docker host.
make_stack() {
local project="$1"
created+=("$project")
docker network create --label "${compose_label}=${project}" "${project}_default" >/dev/null
docker volume create --label "${compose_label}=${project}" "${project}_data" >/dev/null
docker run --detach --name "${project}-db" --label "${compose_label}=${project}" \
--network "${project}_default" -v "${project}_data:/data" --entrypoint sleep "$image" 600 >/dev/null
docker run --detach --name "${project}-browser" --label "${ci_label}=${project}" \
--entrypoint sleep "$image" 600 >/dev/null
printf 'FROM %s\nLABEL %s=%s %s=%s\n' "$image" "$ci_label" "$project" \
"org.riverofglorymn.ekklesia.ci.created" "$(date +%s)" |
docker build --quiet --tag "${project}-image:test" - >/dev/null
docker run --rm --entrypoint mkdir -v /tmp:/host-tmp "$image" -p "/host-tmp/${project}"
}
job_container="test-ci-sweep-job-${nonce}"
cleanup() {
local status=$?
trap - EXIT INT TERM
docker rm --force "$job_container" >/dev/null 2>&1 || true
for project in "${created[@]}"; do
docker ps --all --quiet --filter "label=${compose_label}=${project}" | xargs -r docker rm --force >/dev/null 2>&1 || true
docker ps --all --quiet --filter "label=${ci_label}=${project}" | xargs -r docker rm --force >/dev/null 2>&1 || true
docker network rm "${project}_default" >/dev/null 2>&1 || true
docker volume rm --force "${project}_data" >/dev/null 2>&1 || true
docker image rm --force "${project}-image:test" >/dev/null 2>&1 || true
docker run --rm --entrypoint rm -v /tmp:/host-tmp "$image" -rf "/host-tmp/${project}" >/dev/null 2>&1 || true
done
exit "$status"
}
trap cleanup EXIT INT TERM
# shellcheck source=scripts/lib/ci-project.sh
source "$repository_root/scripts/lib/ci-project.sh"
# Fake run identifiers: nothing here can match a real run's stacks.
export GITHUB_RUN_ID="sweeptest${nonce}"
export GITHUB_RUN_ATTEMPT="1"
mine="$(EKKLESIA_CI_JOB_KEY=alpha ci_project_name mine)"
sibling="$(EKKLESIA_CI_JOB_KEY=beta ci_project_name sibling)"
foreign="ekklesia-sweep-control-${nonce}"
for project in "$mine" "$sibling" "$foreign"; do
make_stack "$project"
done
# A job container connected to this job's stack network, exactly as compose-job-network.sh does.
docker run --detach --name "$job_container" --entrypoint sleep "$image" 600 >/dev/null
docker network connect "${mine}_default" "$job_container"
echo "--- --job removes this job's stacks, at any age, and nothing else"
EKKLESIA_CI_JOB_KEY=alpha bash scripts/ci-sweep.sh --job
check '! project_exists "$mine"' "this job's containers, network, volume, and image are gone"
check '! staging_exists "$mine"' "this job's staging directory on the Docker host is gone"
check 'project_exists "$sibling"' "another job's stack in the same run is left alone"
check 'project_exists "$foreign"' "a stack outside the CI prefix is left alone"
check 'docker inspect "$job_container" >/dev/null 2>&1' \
"a job container connected to the stack's network is disconnected, never removed"
echo "--- --job refuses to run outside CI, where it could remove a developer's own stacks"
check '! env -u GITHUB_RUN_ID bash scripts/ci-sweep.sh --job >/dev/null 2>&1' \
"--job without GITHUB_RUN_ID fails"
echo "--- --stale removes only CI stacks older than its threshold"
bash scripts/ci-sweep.sh --stale
check 'project_exists "$sibling"' "a young CI stack survives the default threshold"
bash scripts/ci-sweep.sh --stale --max-age-seconds 0
check '! project_exists "$sibling"' "a CI stack older than the threshold is removed"
check '! staging_exists "$sibling"' "its staging directory too"
check 'project_exists "$foreign"' "a stack outside the CI prefix is never removed, at any age"
if ((failures > 0)); then
echo "test-ci-sweep: ${failures} check(s) failed." >&2
exit 1
fi
echo "CI sweep checks passed."
+21 -4
View File
@@ -7,6 +7,10 @@ cd "$repository_root"
# shellcheck source=scripts/lib/compose-job-network.sh
source "$repository_root/scripts/lib/compose-job-network.sh"
# shellcheck source=scripts/lib/compose-diagnostics.sh
source "$repository_root/scripts/lib/compose-diagnostics.sh"
# shellcheck source=scripts/lib/ci-project.sh
source "$repository_root/scripts/lib/ci-project.sh"
command -v docker >/dev/null || {
echo "Docker is required for the PostgreSQL migration integration suite." >&2
@@ -25,14 +29,18 @@ if ! compose_job_network_initialize; then
exit 1
fi
raw_project="ekklesia-migration-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$$"
project="${raw_project//[^a-zA-Z0-9_-]/-}"
project="${project,,}"
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh).
project="$(ci_project_name migration)"
export POSTGRES_DB="ekklesia_migration_test"
export POSTGRES_USER="ekklesia_migration_test"
export POSTGRES_PASSWORD="temporary-$project-${RANDOM}-${RANDOM}"
# Both host ports are ephemeral: concurrent runs on one Docker daemon must never contend for a
# fixed one. `.env.example` pins the storage port for local development, so it is overridden here
# exactly like the database's. The bound ports are discovered below.
export POSTGRES_PORT="0"
export S3_PORT="0"
compose=(
docker compose
@@ -72,8 +80,17 @@ fi
# and CI already knows how to provision. SeaweedFS is the intended deployment target (ADR 0004), and
# proving the storage contract against a mock instead would have hidden the provider quirks this
# suite exists to catch.
if ! "${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage >/dev/null 2>&1; then
# Masked in any diagnostics below, along with anything else that looks like a credential.
COMPOSE_DIAGNOSTICS_SECRETS=(
"$POSTGRES_PASSWORD"
"$(grep -E '^S3_ACCESS_KEY=' .env.example | cut -d= -f2-)"
"$(grep -E '^S3_SECRET_KEY=' .env.example | cut -d= -f2-)"
)
if ! up_output="$(
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage 2>&1
)"; then
echo "Disposable PostgreSQL and object storage did not become ready." >&2
compose_report_not_ready "$up_output" "${compose[@]}"
exit 1
fi
+8 -2
View File
@@ -3,6 +3,10 @@
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<!-- The API's origin (ADR 0045). The API serving this page fills it in at startup; empty means
this page's own origin, the same-origin topology. Never an inline script, so a strict
script-src needs no exception for it. -->
<meta content="" name="ekklesia-api-origin" />
<title>Ekklesia</title>
</head>
<body style="margin: 0; min-width: 0">
@@ -11,7 +15,9 @@
<div id="ekklesia-host"></div>
<!-- A real module script, from the same origin, with no inline script anywhere on the page:
a deployment can therefore apply a strict script-src without exceptions, and the runtime
`import()` of a published module artifact stays same-origin under it. -->
<script src="./app.js" type="module"></script>
`import()` of a published module artifact stays same-origin under it. The path is absolute
and fixed, exactly like the display host's, so it names one host-owned route rather than a
file relative to whatever URL served the page. -->
<script src="/assets/ekklesia-dashboard/app.js" type="module"></script>
</body>
</html>
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/shell",
"version": "0.1.0",
"version": "0.1.2",
"private": true,
"type": "module",
"files": [
@@ -11,6 +11,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./dashboard-assets": {
"types": "./dist/dashboard-assets.d.ts",
"import": "./dist/dashboard-assets.js"
},
"./display-assets": {
"types": "./dist/display-assets.d.ts",
"import": "./dist/display-assets.js"
+24 -9
View File
@@ -1,4 +1,4 @@
import { copyFile, mkdir, rm } from "node:fs/promises";
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import esbuild from "esbuild";
@@ -23,12 +23,16 @@ const outputDirectory = path.join(packageDirectory, "dist", "browser");
const serve = process.argv.includes("--serve");
/**
* Empty means "the API is on this page's own origin" — the expected production shape. Set it to an
* absolute origin for split-origin development (`EKKLESIA_API_BASE_URL=http://127.0.0.1:3000`), and
* add this page's own origin to `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` so the API reflects it for CORS
* and the session cookie travels.
* Development only: the API origin the development server writes into its copy of the Dashboard page,
* exactly as `apps/api` does at startup in production (ADR 0045). Empty means "the API is on this
* page's own origin". Set it to an absolute origin for split-origin development
* (`EKKLESIA_API_BASE_URL=http://127.0.0.1:3000`), and add this page's own origin to
* `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` so the API reflects it for CORS and the session cookie travels.
* A production build never reads it: the built page always carries an empty value, and the bundle
* carries no origin at all.
*/
const apiBaseUrl = process.env.EKKLESIA_API_BASE_URL ?? "";
const apiBaseUrl = serve ? (process.env.EKKLESIA_API_BASE_URL ?? "") : "";
const API_ORIGIN_PLACEHOLDER = '<meta content="" name="ekklesia-api-origin" />';
/** @type {import("esbuild").BuildOptions} */
const buildOptions = {
@@ -37,12 +41,13 @@ const buildOptions = {
// artifacts it is told about, which are real URLs rather than bare specifiers.
bundle: true,
define: {
__EKKLESIA_API_BASE_URL__: JSON.stringify(apiBaseUrl),
// React reads this at module scope and a browser has no `process`.
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
},
entryPoints: {
app: path.join(packageDirectory, "src", "browser", "main.tsx"),
// Emitted under the exact path `apps/api` serves it from (ADR 0044), so the development server's
// `servedir` and production answer the same absolute URL `index.html` names.
"assets/ekklesia-dashboard/app": path.join(packageDirectory, "src", "browser", "main.tsx"),
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
// The display service worker (ADR 0043). A separate entry because a worker is a separate
// realm with its own globals; bundling it into the page would make it unregisterable and its
@@ -65,7 +70,17 @@ const buildOptions = {
await rm(outputDirectory, { force: true, recursive: true });
await mkdir(outputDirectory, { recursive: true });
await mkdir(path.join(outputDirectory, "display"), { recursive: true });
await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html"));
const dashboardPage = await readFile(path.join(packageDirectory, "index.html"), "utf8");
if (dashboardPage.split(API_ORIGIN_PLACEHOLDER).length !== 2) {
throw new Error("index.html must contain exactly one empty ekklesia-api-origin meta element.");
}
await writeFile(
path.join(outputDirectory, "index.html"),
dashboardPage.replace(
API_ORIGIN_PLACEHOLDER,
`<meta content="${apiBaseUrl.replaceAll("&", "&amp;").replaceAll('"', "&quot;")}" name="ekklesia-api-origin" />`,
),
);
await copyFile(
path.join(packageDirectory, "display.html"),
path.join(outputDirectory, "display", "index.html"),
+33
View File
@@ -277,3 +277,36 @@ export function readAuthLanding(search: string): HostAuthLanding | undefined {
}
return undefined;
}
/**
* Captures what an emailed verification or reset link asked for, then removes it from the address
* bar.
*
* A password-reset token is a bearer credential for one account. Leaving it in `location.search`
* would put it in the browser's history, in the tab title's shareable URL, and in the `Referer` of
* anything the page subsequently loaded. `history.replaceState` rewrites the entry in place rather
* than pushing a new one, so the Back button cannot return to the URL that held it. It is
* deliberately *not* stored anywhere afterwards — it lives only in React state for the lifetime of
* the reset form, and never in `localStorage` or `sessionStorage`.
*
* The location and history are passed in rather than read here, so this module still reads no
* global and `browser/main.tsx` remains the shell's one browser-global boundary. Only a recognized
* Ekklesia landing rewrites the URL; any other query is left exactly as it arrived.
*/
export function consumeAuthLanding(
location: Pick<Location, "pathname" | "search">,
history: Pick<History, "replaceState">,
): HostAuthLanding | undefined {
const landing = readAuthLanding(location.search);
if (!landing) {
return undefined;
}
try {
history.replaceState(null, "", location.pathname);
} catch {
// A browser that refuses the rewrite (an exotic sandbox, a file: URL) must still be able to
// finish the flow: the token is already captured, and failing here would strand the person on a
// page that cannot do anything.
}
return landing;
}
+35 -53
View File
@@ -1,61 +1,27 @@
import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
import { EkklesiaUiStyles } from "@ekklesia/ui";
import { createRoot } from "react-dom/client";
import { type HostAuthLanding, readAuthLanding } from "../auth-protocol.js";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { consumeAuthLanding } from "../auth-protocol.js";
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
import { HostApp } from "./HostApp.js";
/**
* Replaced at build time by `scripts/build-browser.mjs` with a JSON string literal: the API origin
* when `EKKLESIA_API_BASE_URL` was set for the build, and `""` otherwise. Declared rather than read
* from `process.env`, because a browser has no `process` and the substitution must be visible here
* instead of hidden in bundler configuration.
*/
declare const __EKKLESIA_API_BASE_URL__: string;
/**
* The one place the shell touches a browser global.
*
* An empty build-time value means the API is served from the same origin as this page, which is the
* expected production shape; a non-empty one is the split-origin development shape the platform's
* trusted-origin configuration already supports (`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` must list this
* page's origin for the session cookie to travel). Everything downstream receives the resolved
* origin as a plain argument, so no other file in this package reads `window`.
* The API that served this page wrote its own origin into `<meta name="ekklesia-api-origin">`
* (ADR 0045): empty in the same-origin topology, the API's hostname when the Dashboard and the API
* live on separate origins. Read at run time, so one built shell serves either topology. Everything
* downstream receives the resolved origin as a plain argument, so no other file in this package
* reads `window`.
*/
const config: EkklesiaHostConfig = {
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
};
const apiBaseUrl = resolveApiOrigin(
document.querySelector<HTMLMetaElement>('meta[name="ekklesia-api-origin"]')?.content,
window.location.origin,
);
/**
* Captures what an emailed verification or reset link asked for, then removes it from the address
* bar.
*
* A password-reset token is a bearer credential for one account. Leaving it in `location.search`
* would put it in the browser's history, in the tab title's shareable URL, and in the `Referer` of
* anything the page subsequently loaded. `history.replaceState` rewrites the entry in place rather
* than pushing a new one, so the Back button cannot return to the URL that held it. It is
* deliberately *not* stored anywhere afterwards — it lives only in React state for the lifetime of
* the reset form, and never in `localStorage` or `sessionStorage`.
*
* The parse is a pure function that recognizes only Ekklesia's own two landings, so nothing in the
* query string can name a destination.
*/
function captureAuthLanding(): HostAuthLanding | undefined {
const landing = readAuthLanding(window.location.search);
if (!landing) {
return undefined;
}
try {
window.history.replaceState(null, "", window.location.pathname);
} catch {
// A browser that refuses the rewrite (an exotic sandbox, a file: URL) must still be able to
// finish the flow: the token is already captured, and failing here would strand the person on a
// page that cannot do anything.
}
return landing;
}
const authLanding = captureAuthLanding();
/** Read once, before anything renders, and stripped from the address bar in the same step. See
* `consumeAuthLanding` for why the token must not stay in the URL. */
const authLanding = consumeAuthLanding(window.location, window.history);
const container = document.getElementById("ekklesia-host");
if (!container) {
@@ -73,13 +39,29 @@ if (!container) {
*/
installEkklesiaDesignTokens(document);
/**
* A page whose API origin is not a canonical origin fails closed: it makes no request at all, because
* every request it would make carries credentials to an address it cannot vouch for. The message is
* fixed and names no address.
*/
function renderHost(config: EkklesiaHostConfig | undefined) {
if (!config) {
return (
<main data-testid="host-configuration-invalid" role="alert">
This page cannot start: the server address it was given is not valid.
</main>
);
}
return authLanding ? (
<HostApp authLanding={authLanding} config={config} />
) : (
<HostApp config={config} />
);
}
createRoot(container).render(
<>
<EkklesiaUiStyles />
{authLanding ? (
<HostApp authLanding={authLanding} config={config} />
) : (
<HostApp config={config} />
)}
{renderHost(apiBaseUrl === undefined ? undefined : { apiBaseUrl })}
</>,
);
+15
View File
@@ -0,0 +1,15 @@
/**
* Exact built files a deployable app may publish for the Ekklesia Dashboard (ADR 0044).
*
* The same shape as `./display-assets.ts`, for the same reason: these are file URLs relative to this
* package's compiled `dist/dashboard-assets.js`, not repository paths and not request-derived
* selectors. `apps/api` reads these exact two files before binding and serves their bytes at fixed
* host-owned routes on the same origin as `/api`. Nothing here exposes a directory or a generic
* static-file root, and the source map beside the script is deliberately not listed.
*/
const packageRoot = new URL("../", import.meta.url);
export const dashboardHostAssetUrls = Object.freeze({
html: new URL("dist/browser/index.html", packageRoot).href,
script: new URL("dist/browser/assets/ekklesia-dashboard/app.js", packageRoot).href,
});
+6 -3
View File
@@ -6,10 +6,13 @@ import { parseDisplayRoute } from "../host-protocol.js";
import { DisplayApp } from "./DisplayApp.js";
import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js";
declare const __EKKLESIA_API_BASE_URL__: string;
/**
* The display host is always served from the API's own origin, in either topology (ADR 0045): its
* device cookies, pairing, descriptors, module requests, and realtime are all same-origin, and its
* offline cache stores only same-origin responses (ADR 0043).
*/
const config: EkklesiaHostConfig = {
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
apiBaseUrl: window.location.origin,
};
const container = document.getElementById("ekklesia-display");
+33 -4
View File
@@ -4,10 +4,11 @@ import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
* How this shell reaches the Ekklesia API.
*
* `apiBaseUrl` is an absolute **origin** (`https://ekklesia.example.test`, `http://127.0.0.1:3000`)
* rather than something read from a global. The browser entry point supplies it — from a build-time
* value when the shell and the API are served from different origins in development, and from
* `window.location.origin` otherwise — so every function below is a pure function of its arguments
* and can be exercised without a DOM, a `window`, or a page. Nothing here reads a global.
* rather than something read from a global. The browser entry point supplies it — from the API
* origin the serving API wrote into the Dashboard page (ADR 0045, {@link resolveApiOrigin}), and from
* `window.location.origin` for the display host, which is always served from the API's own origin —
* so every function below is a pure function of its arguments and can be exercised without a DOM, a
* `window`, or a page. Nothing here reads a global.
*/
export interface EkklesiaHostConfig {
readonly apiBaseUrl: string;
@@ -702,6 +703,34 @@ export function parseHostPrincipal(payload: unknown): ModuleUiPrincipal {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
/**
* The API origin a Dashboard page was served with (ADR 0045).
*
* `declared` is the `content` of the page's `<meta name="ekklesia-api-origin">`, which the serving API
* fills in at startup. Absent or empty means the API shares this page's origin, the same-origin
* topology. Anything else must be exactly a canonical `http(s)` origin — no path, no trailing slash,
* no credentials, no query — or the result is `undefined` and the page must fail closed rather than
* send credentialed requests to an address it cannot vouch for.
*/
export function resolveApiOrigin(
declared: string | null | undefined,
pageOrigin: string,
): string | undefined {
if (declared === null || declared === undefined || declared === "") {
return pageOrigin;
}
let url: URL;
try {
url = new URL(declared);
} catch {
return undefined;
}
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.origin !== declared) {
return undefined;
}
return url.origin;
}
/** Resolves a root-relative platform path against the configured API origin. The one place the
* shell turns a server-produced path into an absolute URL. */
export function resolveApiUrl(config: EkklesiaHostConfig, rootRelativePath: string): string {
+1
View File
@@ -32,6 +32,7 @@ export {
parseDisplayRoute,
parseHostModuleDisplay,
parseHostPrincipal,
resolveApiOrigin,
resolveApiUrl,
} from "./host-protocol.js";
export {
+53 -1
View File
@@ -1,7 +1,7 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { HostApp } from "../src/browser/HostApp.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
@@ -140,6 +140,58 @@ async function submit(hostTestId: string): Promise<void> {
});
}
describe("consuming an emailed-link landing", () => {
it("captures a reset token and strips the whole query from the address bar in place", () => {
window.history.replaceState({}, "", "/?ekklesia_auth=reset-password&token=secret-reset-token");
const lengthBefore = window.history.length;
const landing = consumeAuthLanding(window.location, window.history);
expect(landing).toEqual({ kind: "reset-password", token: "secret-reset-token" });
expect(window.location.search).toBe("");
expect(window.location.href).not.toContain("secret-reset-token");
// Replaced, not pushed: Back cannot return to the entry that held the token.
expect(window.history.length).toBe(lengthBefore);
});
it("strips every recognized landing, including a failed verification and an invalid reset link", () => {
for (const search of [
"?ekklesia_auth=verify-email",
"?ekklesia_auth=verify-email&error=TOKEN_EXPIRED",
"?ekklesia_auth=reset-password&error=INVALID_TOKEN",
"?ekklesia_auth=reset-password&token=not%20a%20valid%20token",
]) {
window.history.replaceState({}, "", `/${search}`);
expect(consumeAuthLanding(window.location, window.history), search).toBeDefined();
expect(window.location.search, search).toBe("");
}
});
it("leaves an unrelated query untouched and rewrites nothing", () => {
window.history.replaceState({}, "", "/?unrelated=1");
const replaceState = vi.fn();
expect(consumeAuthLanding(window.location, { replaceState })).toBeUndefined();
expect(replaceState).not.toHaveBeenCalled();
expect(window.location.search).toBe("?unrelated=1");
});
it("still returns the captured landing when the browser refuses the rewrite", () => {
const replaceState = vi.fn(() => {
throw new Error("SecurityError");
});
expect(
consumeAuthLanding(
{ pathname: "/", search: "?ekklesia_auth=reset-password&token=abc123" },
{ replaceState },
),
).toEqual({ kind: "reset-password", token: "abc123" });
expect(replaceState).toHaveBeenCalledWith(null, "", "/");
});
});
describe("emailed-link landing parsing", () => {
it("recognizes exactly Ekklesia's own two landings", () => {
expect(readAuthLanding("?ekklesia_auth=verify-email")).toEqual({ kind: "verified" });
@@ -0,0 +1,71 @@
// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { dashboardHostAssetUrls } from "../src/dashboard-assets.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("Dashboard host asset locator", () => {
it("exports only frozen immutable href strings for the two exact built files, never the source map", () => {
expect(Object.isFrozen(dashboardHostAssetUrls)).toBe(true);
expect(dashboardHostAssetUrls).toEqual({
html: new URL("dist/browser/index.html", `file://${packageDirectory}/`).href,
script: new URL(
"dist/browser/assets/ekklesia-dashboard/app.js",
`file://${packageDirectory}/`,
).href,
});
expect(JSON.stringify(dashboardHostAssetUrls)).not.toContain(".map");
});
it("publishes the locator through a narrow package subpath, not the shell root barrel", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { exports?: Record<string, unknown> };
expect(manifest.exports?.["./dashboard-assets"]).toEqual({
import: "./dist/dashboard-assets.js",
types: "./dist/dashboard-assets.d.ts",
});
const rootSource = await readFile(path.join(packageDirectory, "src/index.ts"), "utf8");
expect(rootSource).not.toContain("dashboardHostAssetUrls");
expect(rootSource).not.toContain("dashboard-assets");
});
it("loads exactly one fixed, absolute, same-origin script and no inline script", async () => {
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
expect(html).toContain('id="ekklesia-host"');
expect(html.match(/<script\b/gu)).toHaveLength(1);
expect(html).toContain(
'<script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
);
});
it("carries exactly one empty API-origin meta element for the serving API to fill (ADR 0045)", async () => {
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
expect(html.match(/name="ekklesia-api-origin"/gu)).toHaveLength(1);
expect(html).toContain('<meta content="" name="ekklesia-api-origin" />');
});
it("bakes no API origin into the bundle: the build defines nothing but NODE_ENV", async () => {
const buildScript = await readFile(
path.join(packageDirectory, "scripts/build-browser.mjs"),
"utf8",
);
expect(buildScript).not.toContain("__EKKLESIA_API_BASE_URL__");
});
it("builds the Dashboard entry at the exact path the locator names", async () => {
const buildScript = await readFile(
path.join(packageDirectory, "scripts/build-browser.mjs"),
"utf8",
);
expect(buildScript).toContain('"assets/ekklesia-dashboard/app": path.join(');
});
});
@@ -6,6 +6,7 @@ import {
loadHostPrincipal,
parseDashboardNavigation,
parseHostPrincipal,
resolveApiOrigin,
} from "../src/host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
@@ -101,6 +102,39 @@ function navigationWithModule(module: unknown): unknown {
return { groups: [], ungrouped: [{ module, name: "Example Module" }] };
}
describe("host protocol — API origin (ADR 0045)", () => {
const pageOrigin = "https://dash.example.test";
it("uses the page's own origin when the page declares none (same-origin topology)", () => {
expect(resolveApiOrigin(undefined, pageOrigin)).toBe(pageOrigin);
expect(resolveApiOrigin(null, pageOrigin)).toBe(pageOrigin);
expect(resolveApiOrigin("", pageOrigin)).toBe(pageOrigin);
});
it("uses a declared canonical origin (split topology)", () => {
expect(resolveApiOrigin("https://api.example.test", pageOrigin)).toBe(
"https://api.example.test",
);
expect(resolveApiOrigin("http://127.0.0.1:3000", pageOrigin)).toBe("http://127.0.0.1:3000");
});
it.each([
["a trailing slash", "https://api.example.test/"],
["a path", "https://api.example.test/api"],
["a query", "https://api.example.test?x=1"],
["credentials", "https://user:[email protected]"],
["a non-canonical spelling", "HTTPS://API.example.test"],
["a default port spelled out", "https://api.example.test:443"],
["another scheme", "javascript:alert(1)"],
["a protocol-relative value", "//api.example.test"],
["a relative value", "/api"],
["surrounding whitespace", " https://api.example.test"],
["something that is not a URL", "api.example.test"],
])("refuses %s, so the page fails closed", (_case, declared) => {
expect(resolveApiOrigin(declared, pageOrigin)).toBeUndefined();
});
});
describe("host protocol — descriptor validation", () => {
it("parses a well-formed descriptor out of the navigation view", () => {
const navigation = parseDashboardNavigation(navigationWithModule(inventoryPayload.modules[0]));
-2
View File
@@ -120,7 +120,6 @@ describe("the shell builds no module knowledge into its bundle", () => {
absWorkingDir: packageDirectory,
bundle: true,
define: {
__EKKLESIA_API_BASE_URL__: '""',
"process.env.NODE_ENV": '"production"',
},
entryPoints: [path.join(sourceDirectory, "browser", "main.tsx")],
@@ -200,7 +199,6 @@ describe("the display host is a separate generic browser bundle", () => {
absWorkingDir: packageDirectory,
bundle: true,
define: {
__EKKLESIA_API_BASE_URL__: '""',
"process.env.NODE_ENV": '"production"',
},
entryPoints: [path.join(sourceDirectory, "display", "main.tsx")],
+1
View File
@@ -10,6 +10,7 @@
},
"include": [
"src/index.ts",
"src/dashboard-assets.ts",
"src/display-assets.ts",
"src/host-protocol.ts",
"src/module-context.ts"
+55
View File
@@ -56,6 +56,10 @@ describe("repository dependency boundaries", () => {
["design-package-depends-upward", "design-packages-depend-downward-only"],
["cross-package-relative", "workspace-imports-use-public-exports"],
["cycle", "no-circular-source-dependencies"],
["runtime-peer-only-dependency", "runtime-imports-are-production-dependencies"],
["runtime-dev-only-dependency", "runtime-imports-are-production-dependencies"],
["runtime-jsx-implies-react", "runtime-imports-are-production-dependencies"],
["runtime-undeclared-dependency", "runtime-imports-are-production-dependencies"],
])("rejects %s", async (fixture, expectedRule) => {
const result = await checkBoundaries(path.join(fixtures, fixture));
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
@@ -106,6 +110,57 @@ describe("repository dependency boundaries", () => {
expect(uiViolations.map((violation) => violation.message).join(" ")).not.toContain("compat");
});
it("accepts runtime imports declared as production dependencies, and everything that is not shipped runtime code", async () => {
// Production and optional dependencies, a peer kept beside its production declaration, Node
// built-ins, the package's own exports, type-only imports of development dependencies, and
// tests or build scripts that run with development dependencies installed.
const result = await checkBoundaries(path.join(fixtures, "legal-runtime-dependencies"));
expect(result.violations).toEqual([]);
});
it.each([
[
"runtime-peer-only-dependency",
"packages/sdk/src/index.ts",
"drizzle-orm",
"only as a peer dependency",
],
[
"runtime-dev-only-dependency",
"packages/sdk/src/index.ts",
"zod",
"only as a development dependency",
],
// The automatic JSX runtime imports `react/jsx-runtime` without that import appearing in source.
[
"runtime-jsx-implies-react",
"packages/ui/src/index.tsx",
"react",
"only as a peer dependency",
],
[
"runtime-undeclared-dependency",
"modules/calendar/src/server/index.ts",
"sharp",
"not at all",
],
])("reports %s once, naming the file, the package, and how it is declared", async (fixture, from, to, declaredAs) => {
// A workspace package's imports resolve from its own node_modules: a peer is never satisfied
// there by the consumer, and a production install omits development dependencies, so each of
// these imports fails at run time in the production image even though development works.
const result = await checkBoundaries(path.join(fixtures, fixture));
expect(result.violations).toEqual([
{
from,
message: expect.stringContaining(`declares it ${declaredAs}`),
rule: "runtime-imports-are-production-dependencies",
to,
},
]);
});
it("still rejects module UI source that reaches into Core internals", async () => {
// Allowing `.tsx` inside a module changed which *formats* a module may contain. It did not
// change which layers a module may import: module UI is subject to the identical layer rules
+213
View File
@@ -0,0 +1,213 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
/**
* The scripts CI runs against a shared Docker daemon. Several runs, and several jobs of one run, can
* be active on one runner at once (and the inventoried Gitea ignores workflow `concurrency`), so
* nothing they start may claim a fixed host port, a fixed project or network name, or a fixed
* subnet. And when a disposable project does not become ready, the failure must say why without
* printing a credential.
*/
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const execFileAsync = promisify(execFile);
const ciScripts = [
"scripts/test-migrations-integration.sh",
"scripts/smoke-infrastructure.sh",
"scripts/smoke-deploy.sh",
];
async function source(relativePath: string): Promise<string> {
return readFile(path.join(repositoryRoot, relativePath), "utf8");
}
/** Runs the real helper under bash with the given literal secrets and input. */
async function redact(input: string, secrets: readonly string[] = []): Promise<string> {
const helper = path.join(repositoryRoot, "scripts", "lib", "compose-diagnostics.sh");
const script = `source "$1"; shift; COMPOSE_DIAGNOSTICS_SECRETS=("$@"); printf '%s' "$INPUT" | compose_diagnostics_redact`;
const { stdout } = await execFileAsync("bash", ["-c", script, "redact", helper, ...secrets], {
env: { ...process.env, INPUT: input },
});
return stdout;
}
describe("CI scripts on a shared Docker daemon", () => {
it("publish every host port ephemerally, never a fixed one", async () => {
const migrations = await source("scripts/test-migrations-integration.sh");
// `.env.example` pins both ports for local development; the suite must override both.
expect(migrations).toContain('export POSTGRES_PORT="0"');
expect(migrations).toContain('export S3_PORT="0"');
const infrastructure = await source("scripts/smoke-infrastructure.sh");
expect(infrastructure).toContain('export POSTGRES_PORT="${EKKLESIA_SMOKE_POSTGRES_PORT:-0}"');
expect(infrastructure).toContain('export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"');
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).toContain("EKKLESIA_HTTP_PORT=0");
expect(deploy).toContain("EKKLESIA_HTTPS_PORT=0");
for (const relativePath of ciScripts) {
const text = await source(relativePath);
// No `-p 8333:…`, `127.0.0.1:5432:…`, or `PORT=<non-zero>` anywhere.
expect(text, relativePath).not.toMatch(/\b(?:-p|--publish)\s+[^\s]*\d{2,5}:\d/u);
expect(text, relativePath).not.toMatch(/\b[A-Z_]*PORT="?[1-9]\d*"?\s*$/mu);
}
});
it("name every stack through one helper: the reserved CI prefix, the job's key, and a unique suffix", async () => {
const helper = await source("scripts/lib/ci-project.sh");
// Run, attempt, job key, kind, then a per-invocation suffix: unique even when job containers
// share a PID, and findable later by run and job alone (scripts/ci-sweep.sh --job).
expect(helper).toContain(
"ekklesia-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${EKKLESIA_CI_JOB_KEY:-${GITHUB_JOB:-local}}-",
);
expect(helper).toContain("$$-${RANDOM}${RANDOM}");
for (const relativePath of ciScripts) {
const text = await source(relativePath);
expect(text, relativePath).toContain('source "$repository_root/scripts/lib/ci-project.sh"');
expect(text, relativePath).toMatch(/^project="\$\(ci_project_name [a-z"${}_-]+\)"$/mu);
expect(text, relativePath).not.toContain("raw_project=");
expect(text, relativePath).toContain('--project-name "$project"');
}
});
it("label everything the deploy smoke creates outside Compose, so a sweep can find it", async () => {
// Continuation lines joined, so each command is checked whole.
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
const creating = deploy
.split("\n")
.filter((line) => /\bdocker (?:run --detach|volume create|build)\b/u.test(line));
expect(creating.length).toBeGreaterThanOrEqual(4);
for (const line of creating) {
expect(line).toContain('--label "${EKKLESIA_CI_LABEL}=${project}"');
}
});
it("sweep stale stacks before, and this job's own stacks after, every job that starts any", async () => {
const workflow = parse(await source(".github/workflows/ci.yml")) as {
jobs: Record<
string,
{ env?: Record<string, string>; steps: { if?: string; run?: string; name?: string }[] }
>;
};
const startsStacks =
/smoke-infrastructure\.sh|test:migrations:integration|smoke-deploy\.sh|docker build/u;
const jobs = Object.entries(workflow.jobs).filter(([, job]) =>
job.steps.some((step) => startsStacks.test(step.run ?? "")),
);
expect(jobs.map(([id]) => id).sort()).toEqual(
["deploy-smoke", "image-build", "infrastructure-smoke", "migration-smoke"].sort(),
);
for (const [id, job] of jobs) {
expect(job.env?.EKKLESIA_CI_JOB_KEY, id).toBeTruthy();
const runs = job.steps.map((step) => step.run ?? "");
const firstStack = runs.findIndex((run) => startsStacks.test(run));
const stale = runs.findIndex((run) => run.includes("bash scripts/ci-sweep.sh --stale"));
const own = job.steps.findIndex(
(step) =>
step.if === "always()" && (step.run ?? "").includes("bash scripts/ci-sweep.sh --job"),
);
expect(stale, `${id}: stale sweep`).toBeGreaterThanOrEqual(0);
expect(stale, `${id}: stale sweep runs first`).toBeLessThan(firstStack);
expect(own, `${id}: its own cleanup, always`).toBeGreaterThan(firstStack);
expect(own, `${id}: cleanup is the last step`).toBe(job.steps.length - 1);
}
// One matrix leg's cleanup must never remove the other leg's running stack.
expect(workflow.jobs["deploy-smoke"]?.env?.EKKLESIA_CI_JOB_KEY).toContain("matrix.topology");
});
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
expect(deploy).toContain("choose_edge_network() {");
expect(deploy).toContain("docker network inspect --format '{{range .IPAM.Config}}");
expect(deploy).toContain('[[ "$up_output" == *"overlap"* ]]');
});
it("tag the CI image per run and attempt, so concurrent runs of one commit cannot remove it", async () => {
const workflow = await source(".github/workflows/ci.yml");
expect(workflow).toContain(
'ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"',
);
expect(workflow).not.toContain('ekklesia-ci:"$GITHUB_SHA"');
});
it("report every readiness failure with the project's containers and logs, through the redactor only", async () => {
for (const relativePath of ciScripts) {
const text = await source(relativePath);
expect(text, relativePath).toContain(
'source "$repository_root/scripts/lib/compose-diagnostics.sh"',
);
expect(text, relativePath).toContain("compose_report_not_ready");
expect(text, relativePath).toContain("COMPOSE_DIAGNOSTICS_SECRETS=(");
// Compose output is never printed raw: logs are only ever captured (the deploy smoke scans
// the API's for secrets), and a readiness failure's own output is never discarded.
for (const line of text
.split("\n")
.filter((candidate) => candidate.includes('compose[@]}" logs'))) {
expect(line, relativePath).toMatch(/="\$\("\$\{compose\[@\]\}" logs /u);
}
expect(text, relativePath).not.toMatch(/up --detach --wait[^\n]*>\/dev\/null 2>&1/u);
}
});
});
describe("compose diagnostics redaction", () => {
it("masks credential-looking values, URL user information, and Authorization headers", async () => {
const output = await redact(
[
"POSTGRES_PASSWORD=hunter2hunter2",
' "AWS_SECRET_ACCESS_KEY": "abc/def+ghi"',
"S3_ACCESS_KEY=AKIAEXAMPLE other=fine",
"api_key: xyz123",
"connecting to postgresql://ekklesia:s3cr3t@postgres:5432/ekklesia",
"Authorization: Bearer eyJhbGciOi.payload.sig",
"EKKLESIA_IDENTITY_SECRET=0123456789abcdef",
].join("\n"),
);
for (const secret of [
"hunter2hunter2",
"abc/def+ghi",
"AKIAEXAMPLE",
"xyz123",
"s3cr3t",
"eyJhbGciOi",
"0123456789abcdef",
]) {
expect(output).not.toContain(secret);
}
expect(output).toContain("POSTGRES_PASSWORD=[redacted]");
expect(output).toContain("other=fine");
expect(output).toContain("postgresql://[redacted]@postgres:5432/ekklesia");
});
it("masks every occurrence of a supplied literal, wherever it appears", async () => {
const output = await redact("started with temporary-pw-12345 and again temporary-pw-12345", [
"temporary-pw-12345",
]);
expect(output).toBe("started with [redacted] and again [redacted]\n");
});
it("keeps the diagnosis itself readable", async () => {
const diagnosis =
"Error response from daemon: Bind for 127.0.0.1:8333 failed: port is already allocated";
expect(await redact(diagnosis)).toBe(`${diagnosis}\n`);
expect(await redact("duplicate key value violates unique constraint")).toBe(
"duplicate key value violates unique constraint\n",
);
expect(await redact('password authentication failed for user "ekklesia"')).toBe(
'password authentication failed for user "ekklesia"\n',
);
});
it("strips terminal control sequences from container logs", async () => {
expect(await redact("\u001b[2Kpostgres-1 | ready")).toBe("postgres-1 | ready\n");
});
});
+236
View File
@@ -0,0 +1,236 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
/**
* Structural guarantees of the reference deployment (ADR 0044), checked on the files themselves so
* an edit cannot quietly weaken them. `scripts/smoke-deploy.sh` proves the same configuration
* actually runs; this proves it still says what the documentation promises.
*/
interface ComposeService {
build?: unknown;
cap_add?: string[];
cap_drop?: string[];
command?: string[];
env_file?: string[];
environment?: Record<string, string>;
healthcheck?: { disable?: boolean };
image?: string;
init?: boolean;
networks?: Record<string, { ipv4_address?: string } | null>;
ports?: string[];
profiles?: string[];
read_only?: boolean;
security_opt?: string[];
tmpfs?: string[];
}
interface ComposeDocument {
networks?: Record<
string,
{ internal?: boolean; ipam?: { config?: { subnet?: string; ip_range?: string }[] } }
>;
services?: Record<string, ComposeService>;
}
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const deployDirectory = path.join(repositoryRoot, "deploy");
async function compose(file: string): Promise<ComposeDocument> {
// `merge: true` resolves the base file's `<<:` hardening anchors exactly as Compose does.
return parse(await readFile(path.join(deployDirectory, file), "utf8"), {
merge: true,
}) as ComposeDocument;
}
async function text(file: string): Promise<string> {
return readFile(path.join(deployDirectory, file), "utf8");
}
function envAssignments(source: string): Map<string, string> {
const assignments = new Map<string, string>();
for (const line of source.split("\n")) {
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/u.exec(line);
if (match?.[1]) {
assignments.set(match[1], match[2] ?? "");
}
}
return assignments;
}
const DIGEST_PINNED = /^[^\s:@]+(?::[^\s@]+)?@sha256:[0-9a-f]{64}$/u;
describe("reference deployment compose files", () => {
it("keeps PostgreSQL on its internal network only, and publishes no port from the base", async () => {
const base = await compose("compose.yaml");
const services = base.services ?? {};
expect(Object.keys(services).sort()).toEqual(["api", "migrate", "object-storage", "postgres"]);
expect(Object.keys(services.postgres?.networks ?? {})).toEqual(["data"]);
expect(base.networks?.data?.internal).toBe(true);
expect(base.networks?.storage?.internal).toBe(true);
for (const [name, service] of Object.entries(services)) {
expect(service.ports, name).toBeUndefined();
}
for (const overlay of ["compose.proxy.yaml", "compose.tunnel.yaml"]) {
const document = await compose(overlay);
expect(document.services?.postgres, overlay).toBeUndefined();
}
});
it("pins every third-party image by digest and runs the API only from EKKLESIA_IMAGE", async () => {
const documents = await Promise.all(
["compose.yaml", "compose.proxy.yaml", "compose.tunnel.yaml"].map(compose),
);
for (const document of documents) {
for (const [name, service] of Object.entries(document.services ?? {})) {
expect(service.build, name).toBeUndefined();
if (service.image === undefined) {
continue;
}
if (name === "api" || name === "migrate") {
expect(service.image, name).toMatch(/^\$\{EKKLESIA_IMAGE:\?/u);
} else {
expect(service.image, name).toMatch(DIGEST_PINNED);
}
}
}
});
it("hardens api and migrate, keeps migrate operator-only, and never lets it apply by default", async () => {
const services = (await compose("compose.yaml")).services ?? {};
for (const name of ["api", "migrate"]) {
const service = services[name];
expect(service?.init, name).toBe(true);
expect(service?.read_only, name).toBe(true);
expect(service?.tmpfs, name).toEqual(["/tmp"]);
expect(service?.cap_drop, name).toEqual(["ALL"]);
expect(service?.security_opt, name).toContain("no-new-privileges:true");
expect(service?.environment?.NODE_EXTRA_CA_CERTS, name).toBe("/run/ekklesia/tls/ca.crt");
}
const migrate = services.migrate;
expect(migrate?.profiles).toEqual(["ops"]);
expect(migrate?.healthcheck?.disable).toBe(true);
expect(migrate?.command).toEqual(["node", "core/dist/cli/plan-migrations.js"]);
expect(JSON.stringify(services)).not.toContain("apply-migrations");
});
it("serves PostgreSQL and object storage over TLS from the installation-private CA", async () => {
const services = (await compose("compose.yaml")).services ?? {};
expect(services.postgres?.command).toEqual(
expect.arrayContaining([
"ssl=on",
"ssl_min_protocol_version=TLSv1.3",
"hba_file=/etc/ekklesia/pg_hba.conf",
]),
);
expect(services["object-storage"]?.command).toEqual(
expect.arrayContaining(["-s3.port.https=8443"]),
);
const hba = (await text("postgres/pg_hba.conf"))
.split("\n")
.filter((line) => line.trim() !== "" && !line.trim().startsWith("#"))
.map((line) => line.trim().split(/\s+/u));
expect(hba).toEqual([
["local", "all", "all", "trust"],
["hostssl", "all", "all", "all", "scram-sha-256"],
["hostnossl", "all", "all", "all", "reject"],
]);
});
it("makes the proxy the API's only trusted peer, at a fixed address outside the dynamic range", async () => {
const proxyOverlay = await compose("compose.proxy.yaml");
const proxy = proxyOverlay.services?.proxy;
const proxyAddress = proxy?.networks?.edge?.ipv4_address;
expect(proxyAddress).toBe("${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}");
expect(proxyOverlay.services?.api?.environment).toEqual({
EKKLESIA_CLIENT_ADDRESS_HEADER: "x-forwarded-for",
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`,
});
// Doctor runs as `migrate`. Given anything else, it validates a configuration the API is not
// running, and reports "Direct" for an installation that trusts its proxy.
expect(proxyOverlay.services?.migrate?.environment).toEqual(
proxyOverlay.services?.api?.environment,
);
expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([
{
ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}",
subnet: "${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}",
},
]);
expect(proxy?.cap_drop).toEqual(["ALL"]);
expect(proxy?.cap_add).toEqual(["NET_BIND_SERVICE"]);
const tunnel = (await compose("compose.tunnel.yaml")).services?.tunnel;
expect(tunnel?.networks?.edge?.ipv4_address).toBe("${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}");
expect(tunnel?.env_file).toEqual(["${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env"]);
});
it("routes only the API's own paths, overwrites the forwarding header, and never ships local_certs", async () => {
const caddyfile = await text("Caddyfile.example");
const directives = caddyfile
.split("\n")
.filter((line) => !line.trim().startsWith("#"))
.join("\n");
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
expect(directives).toContain("header_up -CF-Connecting-IP");
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
expect(directives).toContain("header_up Host {host}");
expect(directives).toContain("tls_trust_pool file /etc/caddy/ekklesia-ca.crt");
// Caddy's internal CA is a test-only substitution made by scripts/smoke-deploy.sh.
expect(directives).not.toContain("local_certs");
expect(directives).not.toMatch(/tls\s+internal/u);
});
});
describe("reference deployment env examples", () => {
it("contain no secret values", async () => {
const secretKeys = new Map([
["env/ekklesia.env.example", ["EKKLESIA_IDENTITY_SECRET", "S3_ACCESS_KEY", "S3_SECRET_KEY"]],
["env/postgres.env.example", ["POSTGRES_PASSWORD"]],
["env/object-storage.env.example", ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]],
["env/tunnel.env.example", ["TUNNEL_TOKEN"]],
]);
for (const [file, keys] of secretKeys) {
const assignments = envAssignments(await text(file));
for (const key of keys) {
expect(assignments.get(key), `${file} ${key}`).toBe("");
}
}
const ekklesia = envAssignments(await text("env/ekklesia.env.example"));
expect(ekklesia.get("DATABASE_URL")).toBe(
"postgresql://<user>:<password>@postgres:5432/<database>",
);
expect(ekklesia.get("EKKLESIA_DATABASE_TLS_MODE")).toBe("verify-full");
expect(ekklesia.get("S3_ENDPOINT")).toBe("https://object-storage:8443");
});
it("names every variable Core's host configuration reads", async () => {
// The single place executables read the environment (core/src/host/configuration.ts). A new
// variable there without a line here would leave an operator guessing.
const hostConfiguration = await readFile(
path.join(repositoryRoot, "core", "src", "host", "configuration.ts"),
"utf8",
);
const read = new Set(
[...hostConfiguration.matchAll(/environment\.([A-Z][A-Z0-9_]+)/gu)].map((match) => match[1]),
);
const example = await text("env/ekklesia.env.example");
// Set by the image, not by an operator.
read.delete("NODE_ENV");
// Guards against a pattern that silently stops matching and checks nothing.
expect(read.size).toBeGreaterThan(20);
for (const name of read) {
expect(example, String(name)).toMatch(new RegExp(`^#? ?${name}=`, "mu"));
}
expect(example).toContain("EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=");
});
});
@@ -0,0 +1,29 @@
{
"name": "@ekklesia/sdk",
"version": "0.0.0",
"type": "module",
"dependencies": {
"drizzle-orm": "0.45.2",
"react": "19.2.8",
"polyfill": "1.0.0"
},
"peerDependencies": {
"drizzle-orm": "0.45.2",
"react": "19.2.8"
},
"optionalDependencies": {
"fsevents": "2.3.3"
},
"devDependencies": {
"esbuild": "0.28.1",
"type-only": "1.0.0",
"vitest": "4.1.10"
},
"exports": {
".": "./src/index.ts",
"./schema": "./src/index.ts"
},
"ekklesia": {
"layer": "module-sdk"
}
}

Some files were not shown because too many files have changed in this diff Show More