65 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
The_River d593c10f1d Merge pull request 'docs: propose ADR 0044 reference deployment; fix manifest version' (#44) from docs/adr-0044-reference-deployment into main
CI / verify (push) Successful in 46s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 1m43s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/44
2026-10-01 21:27:46 +00:00
ekklesiaandClaude Opus 5.5 06e55cebd5 docs: propose ADR 0044 reference deployment; fix manifest version
CI / verify (pull_request) Successful in 47s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m43s
Add ADR 0044 (Status: Proposed), the single self-hosted reference
deployment composed from pinned sources, and list it in the ADR index.

Correct CLAUDE.md, which described manifest v6; the contracts accept
only manifestVersion 7 (MODULE_MANIFEST_VERSION in
packages/contracts/src/module-manifest.ts).

No code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 16:08:44 -05:00
The_River f3cd5a4152 Merge pull request 'Fix/signage runtime layout' (#43) from fix/signage-runtime-layout into main
CI / verify (push) Successful in 48s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m46s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/43
2026-10-01 00:00:51 +00:00
ekklesia 3a26ef49ad feat: establish shared Ekklesia design system
CI / verify (pull_request) Successful in 48s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m47s
2026-09-30 18:21:09 -05:00
ekklesia 3ca6b2cb32 feat: expose canonical module display URLs 2026-09-30 18:20:40 -05:00
The_River 75324af045 Merge pull request 'fix: stabilize dashboard content rows' (#42) from fix/signage-runtime-layout into main
CI / verify (push) Successful in 1m26s
CI / infrastructure-smoke (push) Successful in 23s
CI / migration-smoke (push) Successful in 1m52s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/42
2026-09-30 02:04:13 +00:00
ekklesia f445a83ea5 fix: stabilize dashboard content rows
CI / verify (pull_request) Successful in 1m30s
CI / infrastructure-smoke (pull_request) Successful in 23s
CI / migration-smoke (pull_request) Successful in 1m52s
2026-09-29 20:50:14 -05:00
The_River a130eb78ea Merge pull request 'fix: keep dashboard shell dimensions stable' (#41) from fix/signage-dashboard-layout into main
CI / verify (push) Successful in 53s
CI / infrastructure-smoke (push) Successful in 23s
CI / migration-smoke (push) Successful in 1m56s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/41
2026-09-29 23:55:56 +00:00
ekklesia 8127cc87b4 style: format signage dashboard shell changes
CI / verify (pull_request) Successful in 55s
CI / infrastructure-smoke (pull_request) Successful in 23s
CI / migration-smoke (pull_request) Successful in 2m6s
2026-09-29 18:52:07 -05:00
ekklesia 2969ed4992 fix: keep dashboard shell dimensions stable
CI / verify (pull_request) Failing after 16s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
2026-09-29 18:15:46 -05:00
The_River 14ac46bd3d Merge pull request 'Phase 8/module platform prerequisites' (#40) from phase-8/module-platform-prerequisites into main
CI / verify (push) Successful in 46s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m45s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/40
2026-09-06 00:41:22 +00:00
ekklesiaandClaude Opus 5 bb7a9eb351 fix: let the display worker ignore requests it does not handle
CI / verify (pull_request) Successful in 48s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m44s
The fetch listener called `respondWith` for every request and re-issued the
unrecognized ones itself. That routes the whole page through the worker,
re-sends request bodies from a different context, and makes the worker's
presence observable on requests it has no interest in.

Classify synchronously and respond only for the shell and artifact URLs this
worker actually owns. Not responding is what leaves the browser doing exactly
what it would have done without a worker at all — which is the property the
allowlist was written to preserve.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 16:17:04 -05:00
ekklesiaandClaude Opus 5 20d5128bad docs: record the five Phase 8 prerequisites, and report contributions
Bring the living documentation in line with what the five passes actually
shipped, and correct the claims they superseded rather than leaving them to
mislead: the module UI context is six members, not five; module styling is
compiled CSS, not inline objects only; the strict manifest is version 7.

Also close a silent failure the new contribution mechanism introduces. A
contribution whose target capability nobody provides — or whose provider moved
outside its declared range — is not published, and the manifest is valid and
the artifact exists, so an operator sees a screen that renders nothing with no
error anywhere. `pnpm modules:validate` now lists each declared contribution
with its capability and range and says whether a compatible provider is
installed, and Doctor's discovery check reports the same per module. Both
derive from the publication registry the API serves from, so neither can
disagree with what a running platform would actually publish.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 16:16:01 -05:00
ekklesiaandClaude Opus 5 ca3f37d5f9 feat: let a display survive its network — as code, not as authorization
A display is the one surface expected to outlive its network: a television on
a wall in a building whose uplink is a consumer router, with nobody standing
in front of it. When that uplink drops and the screen reboots, the browser
shows its own error page — not the display shell, not an Ekklesia message,
nothing that recovers on its own, because there is no Ekklesia code left
running to recover.

Cache the shell and the declared code, in a service worker scoped to
`/display/`: the page, the host script, the module's display artifact, and
every contributed renderer that display actually mounted. All of them are code
whose bytes already confer no authority, and all of them are addressed by a
URL carrying an exact version.

Cache nothing else, ever. Not the descriptor, not `/api/v1/principal`, not a
module or platform API response, not the realtime stream, not a pairing
exchange, and not any cross-origin request — which is what a signed
object-delivery URL is. It is an allowlist rather than a deny list, so the
cost of forgetting a new endpoint is that code goes uncached rather than that
a permission decision gets stored.

The consequence is stated rather than papered over: a display cannot render
module content while offline. Mounting needs a descriptor, a descriptor is
authorization-bearing, and it is never cached — so a revoked device stays
revoked and cached code can never manufacture current authorization. What the
cache buys is that a kiosk rebooting mid-outage boots into Ekklesia's own
offline state and keeps retrying, that reconnecting mounts with nothing left
to download, and that a hiccup fetching 600 KB of artifact after the tiny
descriptor already resolved falls back to identical bytes instead of failing
the display.

Network-first everywhere, so a screen picks up new code the moment the server
returns and can never be trapped on an obsolete build — the failure mode a
cache-first worker produces on a screen nobody visits. Only a complete
same-origin 200 is stored. Cache names carry a policy version and superseded
generations are deleted; artifacts are pruned to the current version per
module, keeping every artifact belonging to that version because a display
artifact and its contributed renderers share one. The worker never caches
itself.

The page gets a small signal built from two sources, because either alone
lies: `navigator.onLine` is optimistic behind a captive portal, and only the
worker knows it actually served from cache. A display shows a quiet corner
indicator rather than blanking a lobby wall, and resolves its descriptor again
by itself when the network returns — nobody presses Retry on a lobby TV.

Every caching decision lives in a module that touches no global, so the
behaviour is exercised directly rather than through a worker a test runner
cannot host. Registration is an enhancement: no service workers, plain HTTP,
or a refusing policy all leave a display that works exactly as before.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 16:08:34 -05:00
ekklesiaandClaude Opus 5 e36485bf99 feat: let a module route stream an image-sized upload, explicitly and bounded
ADR 0017 capped a module route at 1 MiB and said large media belonged to a
future storage design. That design arrived — ADR 0030 gave modules a bucket,
ADR 0031 took Ekklesia out of the read path — and it did not solve the write
path: signed write URLs were deliberately declined, so the only way bytes
reach a bucket is a module's own route, and a photograph is not 1 MiB.

Extend the route contract rather than adding a second HTTP system. A route
declares `bodyMode`: `buffered` (the default, and what every existing route
already meant) keeps the 1 MiB ceiling and materializes the body before the
handler runs; `streamed` raises the ceiling to 32 MiB and counts bytes as the
handler consumes them, holding nothing. The mode is explicit rather than
inferred from the size, because inferring it would mean an author who raised a
limit silently changed how their own handler receives its body and when a 413
can reach their own code.

32 MiB holds one 25 MiB image plus bounded overhead and is also the platform's
absolute ceiling: no route in any mode may declare more, and no unbounded
option exists in the contract. The unit is one file per request — twenty
images are twenty individually bounded requests, and nothing here can express
a 500 MiB aggregate whose failure would waste everything already sent.

Replace the buffering dependency with the platform's own limiter. A declared
Content-Length over the limit is refused synchronously, before authorization
and before a byte is read. Buffered mode now counts what actually arrives
rather than trusting the header, which closes a gap the previous
implementation had. Streamed mode wraps the body in a counting transform;
on overflow the stream errors, the source is cancelled, and the response is
replaced with the same sanitized 413 even if the handler swallowed the error —
a limit a handler can swallow is not a limit. An unread body is cancelled so
an abandoned upload releases its socket, and an abort stays an ordinary failed
request rather than becoming a 413.

Ordering is unchanged and now strictly better: in streamed mode no byte is
read until the handler reads it, and the handler runs only after authorization
succeeds, so an unauthorized caller cannot push an upload through at all.

The live suite uploads a real 25 MiB body over a real socket end to end.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 15:57:44 -05:00
ekklesiaandClaude Opus 5 c444ff570b feat: let one module contribute a browser renderer to another's capability
The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.

Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.

Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.

A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.

The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.

The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.

The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 07:47:10 -05:00
ekklesiaandClaude Opus 5 07835b7b56 feat: give module UI real stylesheets, and every artifact its own root
ADR 0023 refused stylesheet imports for a good reason: esbuild would emit a
`.css` sidecar beside the artifact, nothing would declare it, no host would
fetch it, and it would work in a local test and silently do nothing in a real
host. Inline style objects were the whole story. They cannot express
`:hover`, `:focus-visible`, `:disabled`, `@media`, or `@keyframes` — so an
accessible focus treatment was not expressible, and the shared elements added
in the previous commit are built from real CSS for exactly that reason.

Compile the stylesheet into the artifact instead of beside it. A module
imports plain `.css`; the canonical builder resolves local `@import`s through
a nested bundle and turns each file into its compiled text, which the author
declares as `defineModuleUi({ styles })`. One declared file still contains
everything: nothing extra to publish, nothing to fetch, and no way for CSS to
become an asset nobody serves. The build additionally fails if a CSS output
ever appears beside an artifact, so a future loader change cannot bring the
sidecar back. Two rules are enforced with their own codes: a module stylesheet
may reference `--ekklesia-*` and never declare one, and every `@import` must
resolve locally or the artifact would fetch styles at run time. Plain `.css`
only; other dialects are a toolchain decision needing their own ADR.

Then make the isolation real. The generic loader mounts every browser artifact
— primary, display, and later a contributed renderer — inside an open Shadow
DOM root it creates, installs the artifact's stylesheet there, and hands the
artifact an element inside it. Shadow DOM is the one mechanism a browser
actually enforces; a class prefix or a "scope your selectors" instruction is a
convention one mistake defeats, and the failure mode is a module silently
restyling the Dashboard. It applies unconditionally, including to artifacts
with no stylesheet, because a conditional boundary is one a module can fall
out of by editing a source file. Both wrappers are `display: contents` through
base rules installed before the artifact's own, so layout is unchanged and a
full-viewport display can still override `:host`. Unmount removes the whole
root in a `finally`, so a cleanup that throws cannot leave styles behind, and
remounting cannot accumulate a second copy.

This is a correctness boundary for styles and is not described as a sandbox:
the root is `open` precisely because module code is trusted, same-realm code
that could reach `document` anyway.

An artifact's DOM is no longer a direct child of the host's element, so the
mount ABI is versioned explicitly. `defineModuleUi` stamps
`MODULE_UI_ABI_VERSION` so it can never drift from the SDK the artifact
compiled against, and a version-1 artifact fails with its own code — "rebuild
your module", not a typo hunt. No dual acceptance, matching every manifest
cutover here. Migration is one `pnpm build`.

`pnpm boundaries` allows `.css` only under `src/ui/` and `src/display/`: a
stylesheet anywhere else in a module is the undeclared sidecar again, just
relocated.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 07:21:14 -05:00
ekklesiaandClaude Opus 5 e03d38fcab feat: give every Ekklesia screen one set of design tokens and elements
Modules have owned their own UI since ADR 0021, but the platform has never
said what that UI should look like. Every screen here — the Dashboard shell,
the display host, the module template — invented its own hex colours, font
stack, spacing, and radii, and they already disagreed. A module author had no
vocabulary for "the ordinary panel background", so the only available answer
was to invent one, and two modules would invent two.

Add `@ekklesia/design-tokens`: one validated, enumerable registry of semantic
tokens. A token declares a name, label, description, and default value; its
category and `--ekklesia-*` identifier are derived from the name, so it can
never claim one category while being named for another. Construction rejects
duplicates, malformed names, unknown categories, empty or overlong text, and
any value that is not a bounded literal — no declaration terminator, rule
block, comment, at-rule, `url(`, `!important`, escape, control character, or
markup. A registry that constructs can therefore be emitted as CSS without
escaping, because nothing needing escaping can be in it. That validation is
also what makes a future theme editor implementable rather than aspirational.

Add `@ekklesia/ui`: button, icon button, text input, select, checkbox, field
wrapper, panel, dialog, tabs, table and list primitives, badge, toast, and
empty/error/loading states — every value a token reference, with real
`:focus-visible` treatment and status that never depends on colour alone.
Deliberately small: an element earns its place when two consumers need it.

The stylesheet is a string rather than a `.css` file so the identical bytes
install into a document or an isolated artifact root, and so hover, disabled,
and focus-visible are real CSS rather than JavaScript approximations of them.

`pnpm boundaries` opens exactly two shared roles to modules, by name — every
other role stays rejected — and pins both design packages to the bottom of the
presentation stack. Both are browser-safe by construction, proven by a real
compile of module-shaped source in which `process.env` must fail to typecheck.

The shell installs the defaults at its document root for the Dashboard and the
display; custom properties inherit into module artifacts, so a module reads
`var(--ekklesia-…)` with nothing to import at run time and nothing for a host
to forget to pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 06:59:44 -05:00
The_River b14613decd Merge pull request 'fix: let a deployment name itself in the SMTP greeting' (#39) from phase-7c/smtp-helo-name into main
CI / verify (push) Successful in 41s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m41s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/39
2026-08-26 01:42:03 +00:00
ekklesiaandClaude Opus 5 3563549e86 fix: let a deployment name itself in the SMTP greeting
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m41s
A real deployment could not send mail through an IP-authorized relay.
Every attempt surfaced as MAIL_PROVIDER_UNAVAILABLE while the relay was
healthy and swaks sent from the same machine.

The cause was the greeting, and it is generic rather than tied to any
provider. An SMTP client identifies itself in EHLO, and with nothing
configured Nodemailer derives that from the machine hostname —
substituting the address literal [127.0.0.1] whenever the hostname is
not fully qualified, which is the normal state of a container. The relay
closed the connection on it with a 421 before any message was offered,
which is indistinguishable from the relay being down.

EKKLESIA_SMTP_HELO_NAME now lets the deployment say what to greet as. It
is validated as a fully qualified domain name — no scheme, port,
brackets, whitespace, or control characters, and at least one dot, since
a single unqualified label is exactly the input Nodemailer already
discards. It is not a credential: it travels in the clear on every
connection, so Doctor prints it where an operator can compare it against
what the relay expects, and Doctor's probe performs the same greeting the
runtime does.

Absent preserves Nodemailer's behaviour exactly. It is deliberately not
defaulted to the sender's domain: a machine whose hostname is already a
valid FQDN may be greeting correctly, and some relays check that against
reverse DNS, so replacing it silently would trade one deployment's bug
for another's.

The regression guard is a raw SMTP server that refuses any greeting but
the expected one, because smtp-server exposes no EHLO hook and its
earliest hook is never reached by a send-nothing probe.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 20:28:30 -05:00
The_River 450d65116f Merge pull request 'feat: verify email addresses and recover forgotten passwords' (#38) from phase-7c/email-verification-recovery into main
CI / verify (push) Successful in 41s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 1m42s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/38
2026-08-25 23:45:25 +00:00
ekklesiaandClaude Opus 5 ea28f8aa2e feat: verify email addresses and recover forgotten passwords
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s
Ekklesia could authenticate a person and, since Pass 1, send a
transactional email — but it had no way to establish that an address
belonged to whoever claimed it, and no way back in for someone who
forgot their password.

A verified address is now a precondition for holding a session. Better
Auth enforces that after the password check and before any session
exists, so an unverified sign-in returns 403 with no cookie. That is why
no "unverified principal" was needed: a correct password on an
unverified address carries exactly the authority of no password, and
/api/v1/principal is unchanged.

Better Auth keeps every token. Ekklesia adds no token table, no hashing,
and no expiry clock. It supplies a recipient, a subject, and two bodies,
and the mail provider supplies everything about SMTP.

Verification and reset links are rebuilt from a validated Dashboard
origin, taking only the token — never the URL Better Auth prebuilds from
a caller-supplied callbackURL — so no request can steer where a
recipient lands. Following a verification link mints no session, because
mail infrastructure dereferences links. Reset revokes that person's
other sessions and leaves managed devices alone.

Recovery answers identically for an address with an account, without
one, already verified, or undeliverable — including when SMTP is broken,
which is why the delivery adapter never throws.

The initial platform owner is the one exception: created already
verified by an operator command with no mail prerequisite, so a fresh
server can be signed in to before a relay exists. Migration 0007 extends
that to owners of installations that already exist. Public signup stays
disabled; verification is not a licence to self-register.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 07:21:17 -05:00
302 changed files with 26772 additions and 956 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
+27
View File
@@ -81,3 +81,30 @@ S3_BUCKET=ekklesia-development
# EKKLESIA_SMTP_PASSWORD= # EKKLESIA_SMTP_PASSWORD=
# [email protected] # [email protected]
# EKKLESIA_SMTP_FROM_NAME=Ekklesia # EKKLESIA_SMTP_FROM_NAME=Ekklesia
#
# EKKLESIA_SMTP_HELO_NAME is the name Ekklesia announces itself as in the SMTP EHLO command.
#
# Leave it unset unless a relay refuses the connection. An SMTP client has to identify itself, and
# with nothing configured Nodemailer derives that from the machine hostname — substituting the
# address literal [127.0.0.1] whenever the hostname is not fully qualified, which is the normal
# state of a container or an unnamed VM. Some relays close the connection on that greeting with a
# 421 before any message is offered, which looks exactly like the relay being unreachable.
#
# Set it to a fully qualified domain name your deployment owns — typically the sending domain.
# No scheme, no port, no brackets, and at least one dot; anything else is refused at startup.
# EKKLESIA_SMTP_HELO_NAME=example.org
# Where an emailed verification or password-reset link sends a browser (ADR 0038).
#
# Absent means the Dashboard is served from EKKLESIA_IDENTITY_BASE_URL, which is the ordinary
# same-origin production shape. Set it only when a deployment separates the two, for example an API
# at https://api.example.org and the Dashboard at https://dash.example.org. It must also appear in
# EKKLESIA_IDENTITY_TRUSTED_ORIGINS, or the identity system refuses the emailed link — Ekklesia
# checks that at startup rather than letting every recipient discover it.
#
# This is the authenticated Dashboard host, never the public church website.
# EKKLESIA_DASHBOARD_ORIGIN=https://dash.example.org
#
# Verification and password recovery need a working relay above. The initial platform-owner
# bootstrap (pnpm identity:create-initial-owner) deliberately does not: it creates that one account
# already verified, so a fresh server can be signed in to before any mail exists.
+127 -12
View File
@@ -1,18 +1,13 @@
name: CI name: CI
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used # The server runs Gitea 1.27.2 (docs/development/security.md, "What this server honours"):
# 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:
# #
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls, # * `concurrency:` is honoured: a newer push to a pull request cancels the run it supersedes, so a
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26 # cancelled run's own cleanup is never guaranteed (see the CI stack sweep in each stack job).
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server # * `permissions:` is supported by 1.27 with Gitea's own scopes, but what the job token can do on
# is upgraded and configured, the token's authority is whatever the server grants — not what # this server is not yet proven, so nothing here relies on it being read-only.
# this file requests. # * `timeout-minutes:` is no longer listed as unsupported, but is not verified here either;
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation, # teardown relies on the stack sweep and the runner's own reaper.
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
# #
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action # `persist-credentials: false` below is unaffected by any of this and is enforced by the action
# itself. See docs/development/security.md. # itself. See docs/development/security.md.
@@ -63,6 +58,9 @@ jobs:
needs: verify needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 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: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -72,13 +70,29 @@ jobs:
# in this workflow pushes, so the credential is never needed after checkout. # in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false 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 - name: Exercise PostgreSQL and S3 infrastructure
run: bash scripts/smoke-infrastructure.sh 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: migration-smoke:
needs: verify needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 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: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -88,6 +102,11 @@ jobs:
# in this workflow pushes, so the credential is never needed after checkout. # in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false 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 - name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
@@ -103,3 +122,99 @@ jobs:
- name: Exercise transactional migrations against PostgreSQL - name: Exercise transactional migrations against PostgreSQL
run: pnpm test:migrations:integration 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
+1 -1
View File
@@ -15,7 +15,7 @@ monorepo:
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product - **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
packages. They own their own data and migrations, declare capabilities/permissions through packages. They own their own data and migrations, declare capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a `@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
surface directly. Per ADR 0021 a module owns its UI; manifest v6 permits independently optional surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
primary and full-viewport display artifacts, authored in React against the same role-neutral primary and full-viewport display artifacts, authored in React against the same role-neutral
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while `@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source. `pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
+22 -7
View File
@@ -35,13 +35,21 @@ pnpm infrastructure:config
private source paths. private source paths.
- Use `workspace:` ranges for internal package dependencies. - Use `workspace:` ranges for internal package dependencies.
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may - Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
`pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and other stylesheet dialects, and other presentation formats remain rejected by `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role `src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
bundler configuration, and styling is React inline style objects only for now. Both roles use the browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
same UI SDK context with no presentation-mode flag. bundler configuration. A module's stylesheets are compiled into its declared artifact as text and
installed by the host into that artifact's own Shadow DOM root, so module styles cannot leak into
the Dashboard or another module (ADR 0040). Both roles use the same UI SDK context with no
presentation-mode flag, and the same versioned mount ABI.
- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and
`@ekklesia/ui` (ADR 0039) — so two modules' screens look like one product. That is a named
allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`)
stays rejected inside a module. Reference registered semantic tokens; never define an
`--ekklesia-*` custom property of your own.
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never - Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own `@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
@@ -51,10 +59,17 @@ pnpm infrastructure:config
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
through `GET /api/v1/module-displays/<module-id>`, mounts both with through `GET /api/v1/module-displays/<module-id>`, mounts both with
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033). `@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
- A module may contribute a browser renderer to a capability another module provides
(`manifest.contributions`, ADR 0041). The consumer discovers and mounts it through
`ModuleUiHostContext.contributions`, never by building a publication URL, and owns the input
contract entirely: the platform carries opaque JSON and defines no payload schema, slot, or
ordering. A contributed renderer gets its own id and that input, and nothing else.
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at - Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the `/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
anonymous trusted code. Public/device identity, display inventory, and variants are deferred. anonymous trusted code. Public/device identity, display inventory, and variants are deferred. A
display-scoped service worker caches the shell and declared artifacts only, network-first, and
never a descriptor, an API response, or anything else authorization-bearing (ADR 0043).
- Dashboard groups are presentation configuration, never module structure. Do not add a parent - Dashboard groups are presentation configuration, never module structure. Do not add a parent
module, child module, owning module, or group-derived dependency: real module relationships are module, child module, owning module, or group-derived dependency: real module relationships are
the dependency contract's job alone, and grouping must never influence discovery, dependency the dependency contract's job alone, and grouping must never influence discovery, dependency
+104 -13
View File
@@ -52,7 +52,8 @@ order never grants elevation — but is now **disabled by default, and cannot be
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
[API](docs/development/api.md) and [API](docs/development/api.md) and
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md). [ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
Email verification, password recovery, and invitation/operator enrollment still do not exist. Email verification and password recovery now exist (Phase 7C pass 2, below); invitation and operator
enrollment still do not.
Public registration remains disabled by default and prohibited in production — and a fresh Public registration remains disabled by default and prohibited in production — and a fresh
production installation now has a supported way to get its first administrator anyway: Phase 7B production installation now has a supported way to get its first administrator anyway: Phase 7B
pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow, pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow,
@@ -217,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 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 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 — 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 configuring and proving job-token authority server-side (the server now runs Gitea 1.27.2, which
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and honours `concurrency` and documents `permissions`, but neither token limits nor `timeout-minutes` are
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those 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 prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
gates a **public launch**; moving future public contribution workflows to a service with isolated 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 hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
@@ -288,7 +290,8 @@ artifacts cannot drift.
externalizing it would make every host supply a compatible React — defeating standalone hosting. The externalizing it would make every host supply a compatible React — defeating standalone hosting. The
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template); accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
sharing one runtime later is a build optimization that does not change the contract. Styling is React sharing one runtime later is a build optimization that does not change the contract. Styling is React
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds, `.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
and installing a module or starting the API never triggers one. and installing a module or starting the API never triggers one.
@@ -487,7 +490,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
manifest is version 6 after ADR 0033 added the optional display declaration. manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
browser contributions.
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and **Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
no key management, so a `secret` key's value is supplied by the deployment no key management, so a `secret` key's value is supplied by the deployment
@@ -719,12 +723,31 @@ ambiguous, and retrying it would send someone several copies of the same reset e
single bounded attempt and deliberately not an exactly-once promise. There is **no single bounded attempt and deliberately not an exactly-once promise. There is **no
`POST /api/v1/send-email`** and no route that touches the provider. `POST /api/v1/send-email`** and no route that touches the provider.
Email remains **optional**: an installation with no SMTP configured is a complete deployment, since Email remains **optional**: an installation with no SMTP configured is a complete deployment.
nothing sends mail yet. `pnpm doctor` reports the configuration and can probe connectivity **without `pnpm doctor` reports the configuration and can probe connectivity **without sending anything**,
sending anything**, while stating plainly that a successful connection does not establish while stating plainly that a successful connection does not establish deliverability. See
deliverability. **Email verification, password reset, and invitations are Pass 2** — Better Auth's [Outbound email](docs/development/email.md).
`sendResetPassword` and `sendVerificationEmail` hooks are still unimplemented, and this transport is
the seam they will use. See [Outbound email](docs/development/email.md). Phase 7C pass 2 completes the human identity lifecycle over that transport
([ADR 0038](docs/architecture/decisions/0038-email-verification-and-password-recovery.md)). **A
verified email address is a precondition for holding a session**: the identity system refuses an
unverified sign-in after checking the password and before creating one, so a correct password on an
unverified address carries exactly the authority of no password at all — which is why no
"unverified principal" exists and `/api/v1/principal` is unchanged. Better Auth owns every token;
Ekklesia adds no token table, no token hashing, and no expiry clock. Verification and reset links are
rebuilt from a validated Dashboard origin rather than from anything a request supplied, so no caller
can steer where a recipient lands, and following a verification link deliberately mints **no
session** — mail scanners dereference links. Password reset revokes that person's other sessions and
leaves managed devices untouched. Password recovery answers identically for an address with an
account, without one, already verified, or undeliverable — including when SMTP is broken, which is
why the delivery adapter never throws.
**The initial platform owner is the one exception**: `pnpm identity:create-initial-owner` creates
that account already verified and has no mail prerequisite at all, so a freshly provisioned server
can be signed in to before any relay exists. Normal production use of verification and recovery does
require configured outbound email. **Public signup remains disabled** — verification is not a licence
to self-register — and invitations and user administration are still not built. See
[Identity](docs/development/identity.md).
Ekklesia publishes the display bundle at Ekklesia publishes the display bundle at
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at `GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
@@ -772,6 +795,74 @@ unproven launch-blockers rather than existing guarantees. Read
[Security model and deployment trust boundaries](docs/development/security.md) before installing a [Security model and deployment trust boundaries](docs/development/security.md) before installing a
module or exposing the API. module or exposing the API.
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
None of them is a feature module, and none of them names one.
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
badge, toast, and empty/error/loading states), every value a token reference, with real
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
exactly two shared roles to modules by name and pins both design packages beneath every other role.
The Dashboard and display install the defaults at their document root; custom properties inherit into
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
theme editor, brand builder, second theme, or component catalogue exists.**
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
is versioned with no dual acceptance; migrating is one `pnpm build`.
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
means. Publication requires that another eligible module provides the capability inside the declared
range; a contribution that fails is absent rather than broken. A consumer discovers through
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
database, storage, or credential — and loading one grants its module nothing.
Phase 8 pass 4 closes the object-storage write path
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
oversize is refused before authorization, the buffered path now counts what arrived rather than
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
every contributed renderer that display mounted, network-first so a screen can never be trapped on
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
stated rather than papered over — a display cannot render module content while offline, because
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
the module's responsibility and do not exist yet.**
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
## Quick start ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
@@ -869,7 +960,7 @@ Object storage
Outbound email Outbound email
○ Outbound email not configured ○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet. No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
Result: READY WITH WARNINGS Result: READY WITH WARNINGS
``` ```
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/api", "name": "@ekklesia/api",
"version": "0.1.0", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"files": [ "files": [
@@ -28,11 +28,15 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-host": "workspace:*", "@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/typescript-config": "workspace:*", "@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/pg": "8.20.0", "@types/pg": "8.20.0",
"@types/smtp-server": "3.5.13",
"better-auth": "1.6.23", "better-auth": "1.6.23",
"happy-dom": "20.0.11",
"pg": "8.22.0", "pg": "8.22.0",
"smtp-server": "3.19.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitest": "4.1.10" "vitest": "4.1.10"
}, },
+27
View File
@@ -27,10 +27,13 @@ import type { Context, MiddlewareHandler } from "hono";
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; 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 { createDashboardRoutes } from "./dashboard-routes.js";
import { createDeviceRoutes } from "./device-routes.js"; import { createDeviceRoutes } from "./device-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js"; import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js"; import { createGrantsRoutes } from "./grants-routes.js";
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js"; import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
import { mountModuleDisplayRoutes } from "./module-display-routes.js"; import { mountModuleDisplayRoutes } from "./module-display-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-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 * presentation only — nothing reachable through this store influences discovery, dependency
* ordering, module startup, or any authorization decision. */ * ordering, module startup, or any authorization decision. */
readonly dashboard: DashboardStore; 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; readonly runtime: EkklesiaIdentityRuntime;
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an /** 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 * 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( app.route(
"/", "/",
createGrantsRoutes({ createGrantsRoutes({
+187 -17
View File
@@ -1,6 +1,10 @@
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts"; import {
import type { Context } from "hono"; MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
import { bodyLimit } from "hono/body-limit"; MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
type ModuleRouteBodyMode,
} from "@ekklesia/contracts";
import type { Context, MiddlewareHandler } from "hono";
/** /**
* The platform's one request-body policy, centralized here rather than scattered as magic numbers * The platform's one request-body policy, centralized here rather than scattered as magic numbers
@@ -19,15 +23,20 @@ import { bodyLimit } from "hono/body-limit";
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting * field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
* abuse. * abuse.
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same * - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
* constant a module's own route declaration is validated against at registration) is the hard * constant a module's own **buffered** route declaration is validated against at registration) is
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling: * the ceiling for every body the platform materializes before a handler runs. It has not moved.
* nothing in this platform ever declares, or enforces, a larger single request body than this. * - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to * explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as
* a future dedicated storage design, not to widening this ceiling. * `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: no route, in any mode, may declare or accept a larger
* single request body, and no unbounded option exists anywhere in the contract.
*/ */
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024; export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024; export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
export { MODULE_ROUTE_MAX_BODY_BYTES_CEILING }; export {
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
};
/** The one stable, sanitized public contract for every request-body-size rejection across the /** The one stable, sanitized public contract for every request-body-size rejection across the
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending * platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
@@ -37,12 +46,173 @@ export function requestBodyTooLargeResponse(c: Context): Response {
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413); return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
} }
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413 /**
* contract rather than redefining `onError` per route. Covers both a declared oversized * A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one.
* `Content-Length` and an oversized chunked/streamed body with no declared length — see *
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before * A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately:
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as * the byte counting below bounds the request either way, so refusing here could only ever turn a
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */ * legitimate request away without making an illegitimate one any less bounded. Node's own HTTP
export function platformBodyLimit(maxSize: number) { * parser rejects genuinely malformed framing before this code sees the request at all.
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse }); */
function declaredContentLength(request: Request): number | undefined {
const header = request.headers.get("content-length");
if (header === null || !/^\d{1,15}$/u.test(header.trim())) {
return undefined;
}
return Number.parseInt(header.trim(), 10);
}
/** Releases a body nothing consumed, so an abandoned or oversized upload does not hold a socket
* open waiting to be read. Safe on a stream that is locked, already drained, or already errored. */
function releaseUnreadBody(request: Request): void {
const body = request.body;
if (!body || body.locked) {
return;
}
void body.cancel().catch(() => undefined);
}
/**
* Reads at most `maxSize` bytes, stopping and cancelling the source the moment the running total
* exceeds it. Never allocates beyond what it has already accepted, so an over-limit body costs one
* chunk past the limit rather than the whole upload.
*/
async function readBounded(
body: ReadableStream<Uint8Array>,
maxSize: number,
): Promise<readonly Uint8Array[] | "over-limit"> {
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
return chunks;
}
size += value.byteLength;
if (size > maxSize) {
await reader.cancel().catch(() => undefined);
return "over-limit";
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
}
function streamOf(chunks: readonly Uint8Array[]): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
}
/**
* The platform's one request-body limiter, for every route in the application.
*
* ## Both modes reject a declared oversize before anything else happens
*
* A `Content-Length` above the limit is refused synchronously, before authorization, before the
* handler, and before a single body byte is read. That is the cheap rejection an ordinary
* too-large upload takes.
*
* ## `buffered` — the body is materialized, bounded, before the handler runs
*
* The exact behaviour every route had before streaming existed, now with the counting done here
* rather than by a dependency. Bytes are counted as they arrive and the request is rejected the
* moment the running total passes the limit, so a lying or absent `Content-Length` is bounded by
* what actually arrived rather than by what was claimed. The handler then receives a request whose
* body is fully available and provably within its declared allowance — which is what makes
* `await request.json()` safe in a module handler.
*
* The platform holds at most the route's own declared allowance in memory, and a buffered route may
* declare at most 1 MiB. That bound is the whole reason the buffered ceiling has not moved.
*
* ## `streamed` — the body is counted as the handler consumes it, and never materialized
*
* Nothing is buffered. The body is wrapped in a counting transform and handed to the handler, which
* reads it as a stream; the moment the running total passes the limit the stream errors, the source
* is cancelled, and the response is replaced with the same sanitized `413` — even if the handler
* caught the error and tried to answer anyway. That last part matters: a limit a handler can swallow
* is not a limit.
*
* The consequence a module author must know is that in this mode a `413` can arrive **after** the
* handler has started, so a handler that persists as it reads has to be prepared for a partially
* consumed upload. Writing to a temporary key and committing only after the read completes is the
* pattern; the platform cannot do it for you, because only the module knows what a partial write
* means to its own data.
*
* ## Ordering
*
* This runs before permission evaluation for both modes, so a request that is *declared* too large
* never reaches authorization work. In `streamed` mode no body byte is read until the handler reads
* it, and the handler only runs after authorization succeeds — so an unauthorized caller can never
* push a large upload through the platform at all.
*
* ## Timeouts
*
* Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout`
* (`apps/api/src/server.ts`), not by anything here. A slow upload is a slow *request*, and the
* platform already has one answer for that rather than a second per-route one.
*/
export function platformBodyLimit(
maxSize: number,
mode: ModuleRouteBodyMode = "buffered",
): MiddlewareHandler {
return async function enforceBodyLimit(c, next) {
const request = c.req.raw;
const declared = declaredContentLength(request);
if (declared !== undefined && declared > maxSize) {
return requestBodyTooLargeResponse(c);
}
if (!request.body) {
return next();
}
if (mode === "buffered") {
const chunks = await readBounded(request.body, maxSize);
if (chunks === "over-limit") {
return requestBodyTooLargeResponse(c);
}
c.req.raw = new Request(request, {
body: streamOf(chunks),
// Required by undici whenever a `Request` is constructed from a stream.
duplex: "half",
} as RequestInit);
return next();
}
let overLimit = false;
let total = 0;
const counted = request.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
total += chunk.byteLength;
if (total > maxSize) {
overLimit = true;
// Erroring the transform propagates back to the source and cancels it, so the socket is
// released rather than left draining an upload nobody will read.
controller.error(new Error("REQUEST_BODY_TOO_LARGE"));
return;
}
controller.enqueue(chunk);
},
}),
);
c.req.raw = new Request(request, { body: counted, duplex: "half" } as RequestInit);
await next();
if (overLimit) {
c.res = requestBodyTooLargeResponse(c);
}
releaseUnreadBody(c.req.raw);
return;
};
} }
+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;
});
}
+20 -2
View File
@@ -79,8 +79,10 @@ export interface ApiRuntimeConfig {
* The validated host outbound-email provider, or `undefined` when this installation has none * The validated host outbound-email provider, or `undefined` when this installation has none
* (ADR 0037). * (ADR 0037).
* *
* `undefined` is a complete, valid deployment: nothing in the platform sends email yet, and a * `undefined` is a valid deployment: the platform starts, every existing feature works, and the
* future feature that needs to discovers a structured `MAIL_NOT_CONFIGURED` rather than crashing. * initial platform owner — created already verified by an operator command — can still sign in
* (ADR 0038). What it cannot do is verify a new account or deliver a password reset; each attempt
* reports a sanitized `MAIL_NOT_CONFIGURED` out of band rather than changing any HTTP response.
* These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or * These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or
* API response ever sees the host or the password. * API response ever sees the host or the password.
*/ */
@@ -198,3 +200,19 @@ export function createApiRuntimeConfig(
storage, 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 module: ModuleDatabase;
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase; readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase; 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 /** 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. */ * over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
readonly close: () => Promise<void>; 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 * 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 * authorization schemas plus the module database facade: one validated, timeout-bounded
@@ -58,5 +78,8 @@ export function createApiDatabase(
module: createModuleDatabaseView(pool), module: createModuleDatabaseView(pool),
moduleConfiguration: createModuleConfigurationView(pool), moduleConfiguration: createModuleConfigurationView(pool),
moduleLifecycle: createModuleLifecycleView(pool), moduleLifecycle: createModuleLifecycleView(pool),
ping: async (statementTimeoutMs: number) => {
await pool.query(pingStatement(statementTimeoutMs));
},
}); });
} }
+16 -5
View File
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets"; import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
/** /**
* The two immutable, platform-owned files that make up Ekklesia's display browser host. * The three immutable, platform-owned files that make up Ekklesia's display browser host.
* *
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the * They are read once, before the HTTP listener is bound, from the exact URLs exported by the
* surface package. Request input is never involved in this read and the package is never exposed as * surface package. Request input is never involved in this read and the package is never exposed as
@@ -14,6 +14,10 @@ export interface DisplayHostAssets {
readonly html: string; readonly html: string;
readonly script: string; readonly script: string;
readonly scriptEtag: string; readonly scriptEtag: string;
/** The display's offline service worker (ADR 0043) — a third fixed, platform-owned file, served
* at a path whose directory is its scope so it controls display pages and nothing else. */
readonly serviceWorker: string;
readonly serviceWorkerEtag: string;
} }
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package /** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
@@ -36,18 +40,24 @@ export class DisplayHostAssetError extends Error {
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a * cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
* failed host build rather than a valid page that can never mount anything. * failed host build rather than a valid page that can never mount anything.
*/ */
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets { export function createDisplayHostAssets(
if (html.trim().length === 0 || script.trim().length === 0) { html: string,
script: string,
serviceWorker: string,
): DisplayHostAssets {
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
throw new DisplayHostAssetError(); throw new DisplayHostAssetError();
} }
return Object.freeze({ return Object.freeze({
html, html,
script, script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`, scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
serviceWorker,
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
}); });
} }
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather /** Loads only the three surface-declared files. A missing or unreadable build fails startup rather
* than leaving a display route that can authorize successfully and then return a broken shell. */ * than leaving a display route that can authorize successfully and then return a broken shell. */
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> { export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
try { try {
@@ -55,7 +65,8 @@ export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
// rejected sibling promise is left running after this function has already failed. // rejected sibling promise is left running after this function has already failed.
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8"); const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8"); const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
return createDisplayHostAssets(html, script); const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
return createDisplayHostAssets(html, script, serviceWorker);
} catch { } catch {
throw new DisplayHostAssetError(); throw new DisplayHostAssetError();
} }
+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;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Canonical browser routes owned by the platform's display host.
*
* Kept separate from the route registration module so both the display host and module-UI
* inventory can publish the same path without introducing a dependency cycle.
*/
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
export function moduleDisplayPageUrl(moduleId: string): string {
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
}
export function moduleDisplayDescriptorUrl(moduleId: string): string {
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
}
+65 -27
View File
@@ -6,25 +6,39 @@ import {
getAuthorizedPrincipal, getAuthorizedPrincipal,
requirePermission, requirePermission,
} from "./authorization-middleware.js"; } from "./authorization-middleware.js";
import { etagMatches } from "./conditional-request.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
export {
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
MODULE_DISPLAY_PAGE_PREFIX,
moduleDisplayDescriptorUrl,
moduleDisplayPageUrl,
} from "./module-display-path.js";
import { moduleDisplayDescriptorUrl, moduleDisplayPageUrl } from "./module-display-path.js";
import { moduleApiBaseUrl } from "./module-http-routes.js"; import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js"; import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import { grantedModulePermissionIds } from "./module-ui-inventory.js"; import {
describeModuleContributions,
grantedModulePermissionIds,
type HostModuleContributionDescriptor,
} from "./module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js"; import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under /** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
* `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */ * `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js"; export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
/**
export function moduleDisplayPageUrl(moduleId: string): string { * Where the display's offline service worker is served (ADR 0043).
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`; *
} * The path is chosen for its **directory**: a service worker's default scope is the directory it was
* served from, so `/display/sw.js` controls display pages and nothing else. Serving it from
export function moduleDisplayDescriptorUrl(moduleId: string): string { * `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`; * it with `Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
} *
* `sw.js` cannot collide with a display page: a display page is registered at the exact literal
* `/display/<module-id>` for each display-capable module, and `sw.js` is not a valid module id.
*/
export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js";
/** The portable facts a browser host needs to mount this module's one display artifact. It mirrors /** The portable facts a browser host needs to mount this module's one display artifact. It mirrors
* the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a * the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
@@ -34,9 +48,15 @@ export interface HostModuleDisplayDescriptor {
readonly id: string; readonly id: string;
readonly version: string; readonly version: string;
readonly display: { readonly url: string }; readonly display: { readonly url: string };
/** The public page a screen opens to mount this display. This is distinct from `display.url`,
* which is the compiled artifact imported by the platform host. */
readonly page: { readonly url: string };
readonly api: { readonly baseUrl: string }; readonly api: { readonly baseUrl: string };
readonly realtime: { readonly baseUrl: string }; readonly realtime: { readonly baseUrl: string };
readonly permissions: readonly string[]; readonly permissions: readonly string[];
/** Renderers other modules contributed to capabilities this module provides (ADR 0041) — the
* mechanism a signage-style display uses to show another module's content without importing it. */
readonly contributions: readonly HostModuleContributionDescriptor[];
} }
/** /**
@@ -81,27 +101,16 @@ function displayFailure(c: Context): Response {
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503); 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 /** 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 * creates this exact shape, while this check keeps a test/composition caller from pairing mutable
* bytes with a stale validator. */ * bytes with a stale validator. */
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets { function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
try { try {
const snapshot = createDisplayHostAssets(input.html, input.script); const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
if (snapshot.scriptEtag !== input.scriptEtag) { if (
snapshot.scriptEtag !== input.scriptEtag ||
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
) {
throw new ModuleDisplayConfigurationError(); throw new ModuleDisplayConfigurationError();
} }
return snapshot; return snapshot;
@@ -132,8 +141,10 @@ async function describeDisplay(
viewer, viewer,
module: { module: {
api: { baseUrl: moduleApiBaseUrl(display.moduleId) }, api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
display: { url: moduleDisplayUiArtifactUrl(display) }, display: { url: moduleDisplayUiArtifactUrl(display) },
id: display.moduleId, id: display.moduleId,
page: { url: moduleDisplayPageUrl(display.moduleId) },
permissions: await grantedModulePermissionIds( permissions: await grantedModulePermissionIds(
options.evaluator, options.evaluator,
principal, principal,
@@ -244,6 +255,33 @@ export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutes
}); });
}); });
/**
* The worker itself is `no-cache` with a strong validator, exactly like the host script: a browser
* re-checks it on its own schedule, and the worker deliberately never caches *itself*, which is
* how a screen avoids being trapped on an obsolete worker.
*
* Like the host script and module artifact bytes, fetching this code grants no authority. It
* caches only public code and never a permission decision, so serving it anonymously withholds
* nothing the platform was protecting.
*/
app.get(DISPLAY_SERVICE_WORKER_PATH, (c) => {
if (etagMatches(c.req.header("if-none-match"), assets.serviceWorkerEtag)) {
return new Response(null, {
headers: { "Cache-Control": "no-cache", ETag: assets.serviceWorkerEtag },
status: 304,
});
}
return new Response(assets.serviceWorker, {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/javascript; charset=utf-8",
ETag: assets.serviceWorkerEtag,
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
});
for (const display of displays) { for (const display of displays) {
mountDisplay(app, { ...options, assets }, display); mountDisplay(app, { ...options, assets }, display);
} }
+6 -3
View File
@@ -235,9 +235,12 @@ export function mountModuleHttpRoutes(
const requireRoutePermission = requirePermission(middlewareOptions, route.permission); const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at // The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
// all) is enforced before permission evaluation ever runs, so an oversized request never // all) is enforced before permission evaluation ever runs, so a request *declared* oversized
// reaches authorization work, let alone the handler or the module database. // never reaches authorization work, let alone the handler or the module database. In
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes); // `streamed` mode no body byte is read until the handler reads it, and the handler runs only
// after authorization succeeds, so an unauthorized caller can never push an upload through
// (ADR 0042).
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
const path = moduleRoutePath(route.moduleId, route.path); const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => { const finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({ const context: ModuleRouteHandlerContext = Object.freeze({
+64 -3
View File
@@ -1,6 +1,10 @@
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts"; import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization"; import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
import type { ModuleUiPublicationRegistry, PublishableModuleUi } from "@ekklesia/core/modules"; import type {
ModuleUiPublicationRegistry,
PublishableModuleContribution,
PublishableModuleUi,
} from "@ekklesia/core/modules";
import { Hono } from "hono"; import { Hono } from "hono";
import type { Context } from "hono"; import type { Context } from "hono";
import { import {
@@ -9,8 +13,13 @@ import {
requirePermission, requirePermission,
} from "./authorization-middleware.js"; } from "./authorization-middleware.js";
import { moduleApiBaseUrl } from "./module-http-routes.js"; import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleDisplayPageUrl } from "./module-display-path.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js"; import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-routes.js"; import {
MODULE_UI_ASSET_PREFIX,
moduleContributionArtifactUrl,
moduleUiArtifactUrl,
} from "./module-ui-routes.js";
/** /**
* The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may * The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may
@@ -40,6 +49,10 @@ export interface HostModuleUiDescriptor {
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication * canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
* routing can change without touching a single line of host or module code. */ * routing can change without touching a single line of host or module code. */
readonly ui: { readonly url: string }; readonly ui: { readonly url: string };
/** Canonical page for this module's separately declared display artifact, or `null` when the
* module publishes no display. Server-produced so neither shell nor module spells platform
* routing or infers deployment topology. */
readonly display: { readonly url: string } | null;
/** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths /** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths
* a module UI asks for (`context.api.request("records")`). Server-produced for the same reason * a module UI asks for (`context.api.request("records")`). Server-produced for the same reason
* `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the * `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the
@@ -64,12 +77,56 @@ export interface HostModuleUiDescriptor {
* nor trusts anything in this array. * nor trusts anything in this array.
*/ */
readonly permissions: readonly string[]; readonly permissions: readonly string[];
/** Renderers other modules contributed to capabilities this module provides (ADR 0041). Empty for
* the overwhelming majority of modules, which provide no extension point. */
readonly contributions: readonly HostModuleContributionDescriptor[];
}
/**
* One renderer another module contributed to a capability **this** module provides (ADR 0041).
*
* The URL is server-produced, exactly like `ui.url`, `api.baseUrl`, and `realtime.baseUrl`: a
* consuming module names a contribution and the host resolves it, so no module ever builds
* Ekklesia's publication URL and every one of them stays hostable elsewhere.
*
* There is no payload schema, slot, order, or rendering hint here, and there will not be: what a
* contributed renderer draws and what shape its input takes are the consuming module's contract,
* carried in the browser between two modules without passing through the platform.
*/
export interface HostModuleContributionDescriptor {
readonly id: string;
readonly version: string;
/** The capability it was contributed to — one this module declares. */
readonly capability: string;
readonly url: string;
} }
export interface HostModuleUiInventory { export interface HostModuleUiInventory {
readonly modules: readonly HostModuleUiDescriptor[]; readonly modules: readonly HostModuleUiDescriptor[];
} }
/**
* The one place a publishable contribution becomes a browser descriptor, shared by the primary-UI
* inventory and the display descriptor so the two can never disagree about what a consuming module
* may mount.
*/
export function describeModuleContributions(
registry: ModuleUiPublicationRegistry,
moduleId: string,
): readonly HostModuleContributionDescriptor[] {
return registry
.contributionsForModule(moduleId)
.map((contribution: PublishableModuleContribution) => ({
capability: contribution.capability,
id: contribution.contributionId,
url: moduleContributionArtifactUrl(
{ moduleId: contribution.moduleId, version: contribution.moduleVersion },
contribution.contributionId,
),
version: contribution.version,
}));
}
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions { export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
/** The same publication registry the artifact route serves from — the single accepted/publishable /** The same publication registry the artifact route serves from — the single accepted/publishable
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs * module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
@@ -116,9 +173,13 @@ async function describeModule(
evaluator: AuthorizationMiddlewareOptions["evaluator"], evaluator: AuthorizationMiddlewareOptions["evaluator"],
principal: EkklesiaPrincipal, principal: EkklesiaPrincipal,
published: PublishableModuleUi, published: PublishableModuleUi,
registry: ModuleUiPublicationRegistry,
): Promise<HostModuleUiDescriptor> { ): Promise<HostModuleUiDescriptor> {
const display = registry.resolveDisplay(published.moduleId, published.version);
return { return {
api: { baseUrl: moduleApiBaseUrl(published.moduleId) }, api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
contributions: describeModuleContributions(registry, published.moduleId),
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
id: published.moduleId, id: published.moduleId,
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions), permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) }, realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
@@ -153,7 +214,7 @@ export async function describeMountableModules(
): Promise<readonly MountableModuleDescription[]> { ): Promise<readonly MountableModuleDescription[]> {
return Promise.all( return Promise.all(
registry.list().map(async (published) => ({ registry.list().map(async (published) => ({
descriptor: await describeModule(evaluator, principal, published), descriptor: await describeModule(evaluator, principal, published, registry),
name: published.name, name: published.name,
})), })),
); );
+41 -3
View File
@@ -89,6 +89,26 @@ export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
return `${moduleUiArtifactUrl(identity)}/display`; return `${moduleUiArtifactUrl(identity)}/display`;
} }
/**
* Canonical browser URL for one of a module's declared browser contributions (ADR 0041).
*
* The literal `contributions` segment is host-owned and cannot collide with the two-segment artifact
* route or the `display` sibling. The contribution id is percent-encoded for the same reason the
* module id and version are: a value that somehow did not come from a validated manifest can only
* ever become one segment's worth of text — never a new segment, a traversal, a query, or a
* fragment — and a value that needed encoding simply fails to resolve against the registry.
*
* Like every other publication helper, this takes **identity only**. There is no parameter for a
* filename or any other artifact selector, so no caller can use it to reach a second file inside a
* module package.
*/
export function moduleContributionArtifactUrl(
identity: ModuleUiIdentity,
contributionId: string,
): string {
return `${moduleUiArtifactUrl(identity)}/contributions/${encodeURIComponent(contributionId)}`;
}
/** /**
* One 404 for every "there is nothing to publish here" case: the module is not installed, the * One 404 for every "there is nothing to publish here" case: the module is not installed, the
* installed module declares no artifact for the requested UI role, or the requested version is not * installed module declares no artifact for the requested UI role, or the requested version is not
@@ -191,9 +211,9 @@ export interface CreateModuleUiRoutesOptions {
* *
* ## The request never touches the filesystem * ## The request never touches the filesystem
* *
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to * `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
* `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's * components**. They are handed to the registry and, on an exact match, the already-canonical
* discovery resolved and validated is read directly. No request value is ever joined, resolved, * `artifactPath` that Core's discovery resolved and validated is read directly. No request value is ever joined, resolved,
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so * normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file * traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
* are all unreachable by construction rather than by filtering. An installed module directory never * are all unreachable by construction rather than by filtering. An installed module directory never
@@ -228,6 +248,24 @@ export interface CreateModuleUiRoutesOptions {
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono { export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
const routes = new Hono(); const routes = new Hono();
routes.get(
`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/contributions/:contributionId`,
async (c) => {
// Three lookup keys, one exact registry match, one already-validated canonical path. Nothing
// is composed, so a contribution artifact is reachable on exactly the same terms as a module
// UI artifact and on no others.
const published = options.registry.resolveContribution(
c.req.param("moduleId"),
c.req.param("version"),
c.req.param("contributionId"),
);
if (!published) {
return notPublished(c);
}
return serveArtifact(c, published);
},
);
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => { routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
const published = options.registry.resolveDisplay( const published = options.registry.resolveDisplay(
c.req.param("moduleId"), c.req.param("moduleId"),
+57 -18
View File
@@ -13,7 +13,12 @@ import {
import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices"; import { createDeviceStore } from "@ekklesia/core/devices";
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host"; import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import {
createIdentityNotifications,
createIdentityRuntime,
type IdentityNotificationReporter,
type IdentityRateLimitOverride,
} from "@ekklesia/core/identity";
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail"; import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
import { import {
createModuleConfigurationStore, createModuleConfigurationStore,
@@ -39,10 +44,12 @@ import {
createClientAddressResolver, createClientAddressResolver,
wrapFetchWithClientAddress, wrapFetchWithClientAddress,
} from "./client-address.js"; } 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 { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { withDevicePrincipals } from "./device-principal.js"; import { withDevicePrincipals } from "./device-principal.js";
import { loadDisplayHostAssets } from "./display-host-assets.js"; import { loadDisplayHostAssets } from "./display-host-assets.js";
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
import { prepareStartupModuleInventory } from "./platform-permissions.js"; import { prepareStartupModuleInventory } from "./platform-permissions.js";
const connectionTimeoutMs = 10_000; const connectionTimeoutMs = 10_000;
@@ -138,6 +145,15 @@ export interface StartApiOptions {
/** Test-only escape hatch: never set by the executable entry point. See /** Test-only escape hatch: never set by the executable entry point. See
* `IdentityRateLimitOverride` for why production must leave this unset. */ * `IdentityRateLimitOverride` for why production must leave this unset. */
readonly rateLimitOverride?: IdentityRateLimitOverride; readonly rateLimitOverride?: IdentityRateLimitOverride;
/**
* Where a verification or reset message that could not be delivered is reported.
*
* Production composition leaves this unset and one fixed sanitized line is written. It exists as a
* seam because the *only* externally observable difference between "the relay is down" and "there
* is no such account" must be this report — every HTTP response is deliberately identical — so a
* test needs somewhere to observe the operational failure the user is never shown.
*/
readonly reportNotificationFailure?: IdentityNotificationReporter;
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the /** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
* executable entry point. See `prepareStartupModuleInventory`. */ * executable entry point. See `prepareStartupModuleInventory`. */
readonly modulesDirectoryOverride?: string; readonly modulesDirectoryOverride?: string;
@@ -430,6 +446,14 @@ export async function startApi(
transport: config.databaseTransport, transport: config.databaseTransport,
}); });
// Both host providers are constructed from validated configuration **before** anything that uses
// them, and neither touches the network while being built: an S3 client opens no connection and a
// Nodemailer transport opens no socket, so an unreachable relay or storage service can never stop
// the platform from booting (ADR 0030, ADR 0037). An installation with neither configured gets
// `undefined` for both, which is a complete, valid deployment.
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
let services: ModuleRuntimeServices; let services: ModuleRuntimeServices;
let runtime: ReturnType<typeof createIdentityRuntime>; let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>; let authorizationStore: ReturnType<typeof createAuthorizationStore>;
@@ -439,11 +463,22 @@ export async function startApi(
runtime = createIdentityRuntime({ runtime = createIdentityRuntime({
config: config.identity, config: config.identity,
db: databaseHandle.identity, db: databaseHandle.identity,
// Identity's two outbound messages, composed here — the one place that knows both the
// validated identity configuration and the host's mail provider. Core's identity code learns
// a recipient, a subject, and two bodies and nothing whatsoever about SMTP (ADR 0038).
notifications: createIdentityNotifications({
authBaseUrl: config.identity.baseUrl,
dashboardOrigin: config.identity.dashboardOrigin,
mail: mailProvider,
...(options.reportNotificationFailure ? { report: options.reportNotificationFailure } : {}),
}),
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}), ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
}); });
authorizationStore = createAuthorizationStore(databaseHandle.authorization); authorizationStore = createAuthorizationStore(databaseHandle.authorization);
deviceStore = createDeviceStore(databaseHandle.devices); deviceStore = createDeviceStore(databaseHandle.devices);
} catch (error) { } catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
@@ -460,21 +495,6 @@ export async function startApi(
// start, before a single module entrypoint is imported. A failure here fails startup rather than // start, before a single module entrypoint is imported. A failure here fails startup rather than
// falling back to "start everything": the platform cannot know what it was told, and starting a // falling back to "start everything": the platform cannot know what it was told, and starting a
// module an administrator disabled is worse than not starting at all. // module an administrator disabled is worse than not starting at all.
// The host's object-storage provider, constructed from validated configuration before any module
// runs — and **without touching the network**. Building the client opens no connection, so a
// storage outage can never stop the platform from booting; a bucket is ensured lazily on first
// use instead (ADR 0030). An installation with no storage configured gets `undefined` here, which
// is a complete, valid deployment.
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
// The host's outbound mail provider, constructed from validated configuration and — like the
// storage client above — **without touching the network**. Building a Nodemailer transport opens
// no socket and sends nothing, so an unreachable relay can never stop the platform from booting;
// the first connection happens when something actually sends (ADR 0037). Nothing sends yet: this
// exists so Pass 2 can hand it to Better Auth's verification and reset hooks. An installation with
// no SMTP configured gets `undefined`, which is a complete, valid deployment.
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle); const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
const moduleConfigurationStore = createModuleConfigurationStore( const moduleConfigurationStore = createModuleConfigurationStore(
databaseHandle.moduleConfiguration, databaseHandle.moduleConfiguration,
@@ -524,9 +544,15 @@ export async function startApi(
// starts and application composition succeeds. // starts and application composition succeeds.
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules); const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined; let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
try { try {
displayHostAssets = displayHostAssets =
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets(); 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) { } catch (error) {
await safeCloseStorage(storageProvider); await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider); await safeCloseMail(mailProvider);
@@ -558,6 +584,10 @@ export async function startApi(
throw error; 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; let app: Hono;
try { try {
app = createApiApp({ app = createApiApp({
@@ -602,6 +632,13 @@ export async function startApi(
}, },
// A typed view over the same single pool as identity, authorization, and the module facade. // A typed view over the same single pool as identity, authorization, and the module facade.
dashboard: createDashboardStore(databaseHandle.dashboard), 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: { devices: {
// `Secure` follows the deployment's own scheme, read from the already-validated identity // `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 // base URL rather than from any request. Production is https by configuration policy, so a
@@ -613,7 +650,8 @@ export async function startApi(
// Principal resolution, extended with managed devices. A human session still wins, and this // Principal resolution, extended with managed devices. A human session still wins, and this
// is the only place a device credential ever becomes a principal. // is the only place a device credential ever becomes a principal.
runtime: withDevicePrincipals({ devices: deviceStore, runtime }), 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) { } catch (error) {
await safeStopModuleRuntime(moduleRuntime); await safeStopModuleRuntime(moduleRuntime);
@@ -659,6 +697,7 @@ export async function startApi(
let shutdownPromise: Promise<void> | undefined; let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => { const shutdown = (): Promise<void> => {
if (!shutdownPromise) { if (!shutdownPromise) {
shuttingDown = true;
shutdownPromise = (async () => { shutdownPromise = (async () => {
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain // 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. // completes: an in-flight module command may still commit and publish its resulting state.
+262 -5
View File
@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto"; 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 { request as httpRequest } from "node:http";
import net from "node:net"; import net from "node:net";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -26,7 +26,19 @@ import {
import { Client, type QueryResultRow } from "pg"; import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest"; import { afterAll, describe, expect, it, vi } from "vitest";
import type { ClientAddressConfigInput } from "../src/client-address.js"; import type { ClientAddressConfigInput } from "../src/client-address.js";
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
import { createApiRuntimeConfig } from "../src/config.js"; 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 { import {
MODULE_UI_CACHE_CONTROL, MODULE_UI_CACHE_CONTROL,
MODULE_UI_CONTENT_TYPE, MODULE_UI_CONTENT_TYPE,
@@ -224,6 +236,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
@@ -329,7 +342,7 @@ async function createGatedRouteModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }], permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
version: "0.1.0", version: "0.1.0",
@@ -373,7 +386,7 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -431,6 +444,39 @@ async function createFixtureModule(
async handler(request, context) { async handler(request, context) {
return Response.json({ created: true, principal: context.principal }, { status: 201 }); return Response.json({ created: true, principal: context.principal }, { status: 201 });
}, },
});
// An image-sized streamed upload (ADR 0042). The handler counts bytes as they
// arrive and never materializes the body, which is exactly what a real module
// storing an upload would do.
http.route({
method: "POST",
path: "/uploads",
permission: "calendar.events.read",
maxBodyBytes: 33554432,
bodyMode: "streamed",
async handler(request) {
let bytes = 0;
if (request.body) {
const reader = request.body.getReader();
for (;;) {
const chunk = await reader.read();
if (chunk.done) { break; }
bytes += chunk.value.byteLength;
}
}
return Response.json({ bytes }, { status: 201 });
},
});
http.route({
method: "POST",
path: "/small-uploads",
permission: "calendar.events.read",
maxBodyBytes: 64,
bodyMode: "streamed",
async handler(request) {
await request.arrayBuffer().catch(() => undefined);
return Response.json({ swallowed: true }, { status: 201 });
},
});` });`
: "" : ""
} }
@@ -478,7 +524,7 @@ async function createUiModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
version, version,
}), }),
@@ -522,6 +568,32 @@ function apiOrigin(running: RunningApi): string {
return `http://127.0.0.1:${address.port}`; return `http://127.0.0.1:${address.port}`;
} }
/**
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs
* (ADR 0038).
*
* Deliberately not a weakening of the policy: it is the test-harness equivalent of an operator with
* database credentials, used only so scenarios that are about something else are not obliged to run
* an SMTP server. That the policy itself is enforced — that an unverified account genuinely cannot
* sign in — is asserted separately, both in this file and in `email-verification.integration.ts`.
*/
async function markEmailVerified(origin: string, email: string): Promise<void> {
const url = databaseUrlByOrigin.get(origin);
if (!url) {
throw harnessError("No database is registered for that API origin.");
}
const client = new Client({ connectionString: url });
await client.connect();
try {
await client.query(
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
[email.trim().toLowerCase()],
);
} finally {
await client.end().catch(() => undefined);
}
}
interface StartConfiguredApiOptions { interface StartConfiguredApiOptions {
readonly clientAddress?: ClientAddressConfigInput; readonly clientAddress?: ClientAddressConfigInput;
/** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */ /** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */
@@ -537,6 +609,16 @@ interface StartConfiguredApiOptions {
readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined; readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined;
} }
/**
* The database each running API in this suite is attached to, keyed by its bound origin.
*
* Needed because Phase 7C Pass 2 made a verified address a precondition for holding a session
* (ADR 0038), so `signUp` below has to mark the account verified before these scenarios — which are
* about cookies, CSRF, principals, sessions, and authorization, not about verification — can sign
* in. Verification itself has its own dedicated end-to-end suite.
*/
const databaseUrlByOrigin = new Map<string, string>();
async function startConfiguredApi( async function startConfiguredApi(
url: string, url: string,
options: StartConfiguredApiOptions = {}, options: StartConfiguredApiOptions = {},
@@ -571,7 +653,7 @@ async function startConfiguredApi(
}, },
{ environment }, { environment },
); );
return startApi(config, { const running = await startApi(config, {
...("rateLimitOverride" in options ...("rateLimitOverride" in options
? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) } ? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) }
: { rateLimitOverride: { max: 1000, window: 1 } }), : { rateLimitOverride: { max: 1000, window: 1 } }),
@@ -585,6 +667,8 @@ async function startConfiguredApi(
? {} ? {}
: { drainTimeoutMsOverride: options.drainTimeoutMsOverride }), : { drainTimeoutMsOverride: options.drainTimeoutMsOverride }),
}); });
databaseUrlByOrigin.set(apiOrigin(running), url);
return running;
} }
async function startMigratedApi( async function startMigratedApi(
@@ -763,6 +847,12 @@ async function signUp(
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers }, headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST", method: "POST",
}); });
if (response.ok) {
// A verified address is a precondition for holding a session (ADR 0038). Sign-up itself always
// writes `emailVerified: false`, so these scenarios — none of which are about verification —
// complete that step out of band rather than standing up an SMTP server apiece.
await markEmailVerified(origin, email);
}
return { body: (await response.clone().json()) as SignUpResult["body"], response }; return { body: (await response.clone().json()) as SignUpResult["body"], response };
} }
@@ -2640,6 +2730,73 @@ describe.sequential("live API — installed module permission end-to-end", () =>
expect(await trustedResponse.json()).toMatchObject({ created: true }); expect(await trustedResponse.json()).toMatchObject({ created: true });
}); });
it("streams one image-sized upload end to end over real HTTP", async () => {
// 25 MiB — an ordinary photograph, and the size the streamed ceiling exists to accommodate. It
// travels through a real socket, a real Node server, and the platform's byte counter, and the
// platform holds none of it (ADR 0042).
const image = new Uint8Array(25 * 1024 * 1024).fill(0x41);
const response = await fetch(`${origin}/api/v1/modules/calendar/uploads`, {
body: image,
headers: {
"content-type": "application/octet-stream",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(201);
expect(await response.json()).toEqual({ bytes: image.byteLength });
}, 60_000);
it("refuses a declared oversize on a streamed route with a sanitized 413", async () => {
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
body: "a".repeat(65),
headers: {
"content-type": "text/plain",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
it("terminates a chunked streamed body that exceeds the limit, even when the handler swallowed it", async () => {
// No declared `Content-Length`, so only the running byte count can bound this — and the
// handler deliberately ignores the stream error and answers `201`. The platform's `413` still
// wins: a limit a handler can swallow is not a limit.
const oversize = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(new Uint8Array(512).fill(0x41));
},
});
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
body: oversize,
duplex: "half",
headers: { cookie: ownerCookie, origin: dashboardOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
}, 30_000);
it("keeps the server healthy after an oversized upload", async () => {
// The socket was released rather than left draining, so the very next request is served
// normally.
const response = await fetch(`${origin}/api/v1/modules/calendar/events/evt-43`, {
headers: { cookie: ownerCookie },
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ eventId: "evt-43" });
});
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => { it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
const admin = await openAdministrativeClient(database.url); const admin = await openAdministrativeClient(database.url);
try { try {
@@ -2832,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", () => { 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 () => { 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"); const database = await createDisposableDatabase("signup_disabled");
+150
View File
@@ -3,11 +3,16 @@ import {
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
type ModuleDatabase, type ModuleDatabase,
type ModuleRuntimeServices, type ModuleRuntimeServices,
moduleIdSchema,
parseEkklesiaUserId,
parsePermissionId,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
type AuthorizationEvaluator, type AuthorizationEvaluator,
type AuthorizationStore, type AuthorizationStore,
CORE_PERMISSION_DECLARATIONS,
CORE_PERMISSION_REGISTRY, CORE_PERMISSION_REGISTRY,
createPermissionRegistry,
type PermissionRegistry, type PermissionRegistry,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
@@ -16,6 +21,7 @@ import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
type ModuleUiPublicationRegistry, type ModuleUiPublicationRegistry,
StagedModuleRealtimeRegistry,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
@@ -25,6 +31,8 @@ import {
} from "../src/app.js"; } from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js"; import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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 { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
@@ -104,6 +112,8 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -121,6 +131,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -355,6 +367,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -378,6 +392,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -403,6 +419,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -430,6 +448,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -457,6 +477,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -481,6 +503,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -507,6 +531,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -530,6 +556,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -555,6 +583,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -587,6 +617,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -620,6 +652,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -658,6 +692,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -678,6 +714,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -700,6 +738,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -721,6 +761,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -740,6 +782,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -766,6 +810,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -795,6 +841,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -827,6 +875,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({ authorization: fakeAuthorization({
evaluator: { authorize: evaluatorAuthorize }, evaluator: { authorize: evaluatorAuthorize },
@@ -850,6 +900,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), 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();
});
});
+25 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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"; const validSecret = "correct-horse-battery-staple-0123456789";
@@ -28,6 +28,9 @@ describe("API runtime configuration", () => {
host: "127.0.0.1", host: "127.0.0.1",
identity: { identity: {
baseUrl: "https://identity.example.test", baseUrl: "https://identity.example.test",
// Defaults to the identity base URL: the Dashboard and the API share one origin unless a
// deployment separates them (ADR 0038).
dashboardOrigin: "https://identity.example.test",
publicSignupEnabled: false, publicSignupEnabled: false,
secret: validSecret, secret: validSecret,
trustedOrigins: ["https://identity.example.test"], trustedOrigins: ["https://identity.example.test"],
@@ -334,3 +337,24 @@ describe("API runtime configuration", () => {
expect(publicText).not.toContain("db.internal.example"); 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);
}
});
});
+7 -1
View File
@@ -27,6 +27,8 @@ import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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 { import {
createInMemoryModuleLifecycleStore, createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor, moduleLifecycleSnapshotFor,
@@ -75,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }), ...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
}, },
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: fixture.name, name: fixture.name,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -139,6 +141,8 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
dashboard: createInMemoryDashboardStore(options.configuration ?? {}), dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({ moduleServices: Object.freeze({
@@ -269,7 +273,9 @@ describe("Dashboard navigation — reading", () => {
// Presentation wraps the host-neutral descriptor; it never rewrites it. // Presentation wraps the host-neutral descriptor; it never rewrites it.
expect(livestream?.module).toEqual({ expect(livestream?.module).toEqual({
contributions: [],
api: { baseUrl: "/api/v1/modules/livestream" }, api: { baseUrl: "/api/v1/modules/livestream" },
display: null,
id: "livestream", id: "livestream",
permissions: [], permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/livestream" }, realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
+1 -1
View File
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
server: "./dist/server/index.js", server: "./dist/server/index.js",
}, },
id: "announcements", id: "announcements",
manifestVersion: 6, manifestVersion: 7,
name: "Announcements fixture", name: "Announcements fixture",
permissions: [ permissions: [
{ {
+18 -7
View File
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/display-assets", () => ({
displayHostAssetUrls: Object.freeze({ displayHostAssetUrls: Object.freeze({
html: "file:///surface/browser/display/index.html", html: "file:///surface/browser/display/index.html",
script: "file:///surface/browser/display/app.js", script: "file:///surface/browser/display/app.js",
serviceWorker: "file:///surface/browser/display/sw.js",
}), }),
})); }));
@@ -20,21 +21,28 @@ import {
describe("display host asset loading", () => { describe("display host asset loading", () => {
beforeEach(() => readFileMock.mockReset()); beforeEach(() => readFileMock.mockReset());
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => { it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
const html = '<!doctype html><div id="ekklesia-display"></div>'; const html = '<!doctype html><div id="ekklesia-display"></div>';
const script = "export const displayHost = true;\n"; const script = "export const displayHost = true;\n";
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script); const serviceWorker = "export const displayWorker = true;\n";
readFileMock
.mockResolvedValueOnce(html)
.mockResolvedValueOnce(script)
.mockResolvedValueOnce(serviceWorker);
const assets = await loadDisplayHostAssets(); const assets = await loadDisplayHostAssets();
expect(readFileMock.mock.calls).toEqual([ expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/display/index.html"), "utf8"], [new URL("file:///surface/browser/display/index.html"), "utf8"],
[new URL("file:///surface/browser/display/app.js"), "utf8"], [new URL("file:///surface/browser/display/app.js"), "utf8"],
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
]); ]);
expect(assets).toEqual({ expect(assets).toEqual({
html, html,
script, script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`, scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
serviceWorker,
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
}); });
expect(Object.isFrozen(assets)).toBe(true); expect(Object.isFrozen(assets)).toBe(true);
}); });
@@ -62,12 +70,15 @@ describe("display host asset loading", () => {
} }
}); });
it("rejects an empty page or script as an unavailable host build", () => { it("rejects an empty page, script, or service worker as an unavailable host build", () => {
for (const [html, script] of [ for (const [html, script, serviceWorker] of [
["", "export {};"], ["", "export {};", "export {};"],
["<html></html>", " \n"], ["<html></html>", " \n", "export {};"],
["<html></html>", "export {};", " \n"],
] as const) { ] as const) {
expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError); expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow(
DisplayHostAssetError,
);
} }
}); });
}); });
@@ -0,0 +1,816 @@
import { createHmac, randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
createBootstrapIdentityCreator,
createIdentityDrizzleDatabase,
type IdentityNotificationFailure,
} from "@ekklesia/core/identity";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { Client, type QueryResultRow } from "pg";
import { SMTPServer } from "smtp-server";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { type RunningApi, startApi } from "../src/server.js";
/**
* Email verification and password recovery, end to end (ADR 0038).
*
* Everything here is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better
* Auth endpoints, the actual SMTP provider, and an actual SMTP server accepting the message. The
* verification and reset links are **extracted from the delivered message** and then followed, which
* is the only way to prove the whole path rather than the pieces — a fake mail function would prove
* that a callback was invoked and nothing about whether the resulting link works.
*
* `smtp-server` runs in-process and is never given a live credential, so nothing here reaches the
* internet and no real email is ever sent.
*
* Public signup is disabled in every scenario, matching production. Accounts that are not the
* bootstrap owner are created through the same trusted server-side creator the bootstrap CLI uses,
* which is deliberately not reachable over HTTP.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
const dashboardOrigin = "http://dashboard.localhost:5173";
const integrationSecret = "integration-test-secret-0123456789abcdefgh";
const initialPassword = "correct horse battery staple";
const replacementPassword = "an entirely different passphrase";
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function baseDatabaseUrl(): string {
const url = process.env[integrationDatabaseUrlEnvironment];
if (typeof url !== "string" || url.trim() === "") {
throw new IntegrationHarnessError("The integration database URL is not configured.");
}
return url;
}
function databaseUrl(name: string): string {
const url = new URL(baseDatabaseUrl());
url.pathname = `/${name}`;
return url.toString();
}
async function openAdministrativeClient(): Promise<Client> {
const client = new Client({ connectionString: baseDatabaseUrl() });
await client.connect();
return client;
}
async function safeQuery<T extends QueryResultRow>(
client: Client,
text: string,
values: readonly unknown[] = [],
): Promise<T[]> {
try {
const result = await client.query<T>(text, [...values]);
return result.rows;
} catch {
// Never rethrows the driver error: it can carry SQL, a connection string, and a host.
throw new IntegrationHarnessError("An integration database statement failed.");
}
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModules,
platformVersion: "0.1.0",
},
});
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModules, { force: true, recursive: true });
}
}
async function freshInstallation(label: string): Promise<string> {
databaseCounter += 1;
const name =
`ekk_verify_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
const admin = await openAdministrativeClient();
try {
await safeQuery(admin, `create database "${name}"`);
} finally {
await admin.end().catch(() => undefined);
}
trackedDatabases.add(name);
const url = databaseUrl(name);
await applyCoreMigrations(url);
return url;
}
/** A delivered message, as the SMTP server actually received it. */
interface DeliveredMessage {
readonly to: readonly string[];
readonly data: string;
}
interface MailHarness {
readonly port: number;
readonly delivered: DeliveredMessage[];
readonly close: () => Promise<void>;
}
const openResources: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const close of openResources.splice(0).reverse()) {
await close().catch(() => undefined);
}
});
/**
* Picks a free port before the API is configured.
*
* The identity base URL is what the emailed link is built from, so it has to be known *before*
* `startApi` binds. Without this the test could only assert the link's shape and would have to
* rewrite its host before following it — which is exactly the part worth proving. Reserving and
* releasing a port leaves a theoretical race, but nothing else on the loopback interface is
* competing for ephemeral ports inside this suite.
*/
async function reservePort(): Promise<number> {
const { createServer } = await import("node:net");
const server = createServer();
const port = await new Promise<number>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address() as AddressInfo;
resolve(address.port);
});
});
await new Promise<void>((resolve) => server.close(() => resolve()));
return port;
}
async function startMailServer(): Promise<MailHarness> {
const delivered: DeliveredMessage[] = [];
const server = new SMTPServer({
authOptional: true,
disabledCommands: ["STARTTLS"],
onData(stream, session, callback) {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString("utf8");
});
stream.on("end", () => {
delivered.push({
data,
to: session.envelope.rcptTo.map((recipient) => recipient.address),
});
callback();
});
},
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.server.address() as AddressInfo;
const harness: MailHarness = {
close: async () =>
await new Promise<void>((resolve) => {
server.close(() => resolve());
}),
delivered,
port: address.port,
};
openResources.push(harness.close);
return harness;
}
interface Installation {
readonly url: string;
readonly api: RunningApi;
readonly origin: string;
readonly mail: MailHarness | undefined;
readonly notificationFailures: IdentityNotificationFailure[];
readonly createIdentity: (input: {
readonly email: string;
readonly password: string;
readonly name: string;
}) => Promise<void>;
}
/**
* A migrated installation with the API running, optionally with a working SMTP relay.
*
* `plaintext` SMTP and an `http` Dashboard origin are only acceptable because this is explicitly the
* development environment; production configuration refuses both, which `identity-config` and
* `mail-contract` assert separately.
*/
async function installation(
label: string,
options: { readonly mail?: boolean } = {},
): Promise<Installation> {
const url = await freshInstallation(label);
const mail = options.mail === false ? undefined : await startMailServer();
const notificationFailures: IdentityNotificationFailure[] = [];
const identity = createIdentityDrizzleDatabase(url, {});
openResources.push(async () => {
await identity.close().catch(() => undefined);
});
const apiPort = await reservePort();
const identityBaseUrl = `http://127.0.0.1:${apiPort}`;
const config = createApiRuntimeConfig(
{
databaseUrl: url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
dashboardOrigin,
publicSignupEnabled: false,
secret: integrationSecret,
trustedOrigins: [dashboardOrigin],
},
...(mail
? {
mail: {
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "127.0.0.1",
port: String(mail.port),
security: "plaintext",
},
}
: {}),
port: apiPort,
},
{ environment: "development" },
);
const api = await startApi(config, {
rateLimitOverride: { max: 1000, window: 1 },
reportNotificationFailure: (failure) => notificationFailures.push(failure),
});
openResources.push(async () => {
await api.shutdown().catch(() => undefined);
});
const address = api.server.address();
if (!address || typeof address !== "object" || address.port !== apiPort) {
throw new IntegrationHarnessError("The API integration server did not bind the reserved port.");
}
return {
api,
createIdentity: createBootstrapIdentityCreator(identity.db),
mail,
notificationFailures,
origin: identityBaseUrl,
url,
};
}
async function post(
origin: string,
path: string,
body: unknown,
headers: Readonly<Record<string, string>> = {},
): Promise<Response> {
return await fetch(`${origin}${path}`, {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST",
redirect: "manual",
});
}
function firstCookiePair(response: Response): string | undefined {
const setCookie = response.headers.get("set-cookie");
return setCookie === null ? undefined : setCookie.split(",")[0]?.split(";")[0]?.trim();
}
async function signIn(
origin: string,
email: string,
password: string,
): Promise<{ readonly response: Response; readonly cookiePair: string | undefined }> {
const response = await post(origin, "/api/auth/sign-in/email", { email, password });
return { cookiePair: firstCookiePair(response), response };
}
async function principalKind(origin: string, cookiePair: string | undefined): Promise<string> {
const response = await fetch(`${origin}/api/v1/principal`, {
headers: cookiePair ? { cookie: cookiePair } : {},
});
const body = (await response.json()) as { readonly kind?: unknown };
return String(body.kind);
}
/**
* The one place a test reads a token, and only ever out of a message this suite's own SMTP server
* received. Nothing in the platform exposes one.
*
* Quoted-printable soft line breaks are undone first: a URL longer than 76 characters is split by
* the encoder, and reassembling it here is what makes the extracted link the one a mail client would
* actually resolve.
*/
function extractLink(message: DeliveredMessage, contains: string): URL {
const unwrapped = message.data.replaceAll("=\r\n", "").replaceAll("=\n", "");
const decoded = unwrapped.replaceAll(/=([0-9A-F]{2})/gu, (_match, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
);
const match = decoded.match(/https?:\/\/[^\s"<>]+/gu)?.find((url) => url.includes(contains));
if (!match) {
throw new IntegrationHarnessError(`No ${contains} link was delivered.`);
}
return new URL(match);
}
function lastMessage(mail: MailHarness): DeliveredMessage {
const message = mail.delivered.at(-1);
if (!message) {
throw new IntegrationHarnessError("No message was delivered.");
}
return message;
}
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
const client = new Client({ connectionString: url });
await client.connect();
try {
const rows = await safeQuery<{ email_verified: boolean }>(
client,
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
[email.toLowerCase()],
);
return rows[0]?.email_verified;
} finally {
await client.end().catch(() => undefined);
}
}
/**
* An account that has completed the real verification round trip.
*
* Recovery is only meaningful for a verified account, and building one through the actual flow —
* rather than by writing `email_verified` directly — keeps every recovery test standing on a
* verification path that genuinely works.
*/
async function verifiedAccountFor(install: Installation, email: string): Promise<void> {
await install.createIdentity({ email, name: "Member", password: initialPassword });
await signIn(install.origin, email, initialPassword);
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
await fetch(link, { redirect: "manual" });
(install.mail as MailHarness).delivered.length = 0;
}
afterAll(async () => {
const admin = await openAdministrativeClient().catch(() => undefined);
if (!admin) {
return;
}
try {
for (const name of trackedDatabases) {
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
() => undefined,
);
}
} finally {
await admin.end().catch(() => undefined);
}
});
/**
* Forges a verification token that is structurally valid but already expired.
*
* Better Auth's verification token is a plain HS256 JWS over the auth secret (`signJWT`, pinned
* release) — not a database row — so an expired one can be produced here from the same secret this
* suite configured, with no library internals and no waiting an hour. This is the only place any
* test constructs a token rather than reading one out of a delivered message.
*/
function forgeExpiredVerificationToken(email: string): string {
const encode = (value: object): string =>
Buffer.from(JSON.stringify(value)).toString("base64url");
const issued = Math.floor(Date.now() / 1000) - 7200;
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({
email: email.toLowerCase(),
exp: issued + 3600,
iat: issued,
})}`;
const signature = createHmac("sha256", integrationSecret)
.update(signingInput)
.digest("base64url");
return `${signingInput}.${signature}`;
}
describe("email verification, end to end", () => {
it("takes a new human account from unverified to signed in through a real emailed link", async () => {
const install = await installation("flow");
const email = "[email protected]";
await install.createIdentity({ email, name: "Member", password: initialPassword });
// 1. Unverified. A correct password does not produce a session — the identity system refuses
// before creating one, so there is no half-authenticated state anywhere (ADR 0038).
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
const refused = await signIn(install.origin, email, initialPassword);
expect(refused.response.status).toBe(403);
expect(await refused.response.clone().json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
expect(refused.cookiePair).toBeUndefined();
expect(await principalKind(install.origin, refused.cookiePair)).toBe("anonymous");
// 2. That attempt delivered a verification message through the real SMTP provider.
const mail = install.mail as MailHarness;
expect(mail.delivered).toHaveLength(1);
expect(lastMessage(mail).to).toEqual([email]);
expect(lastMessage(mail).data).toContain("Verify your Ekklesia email address");
// 3. Follow the link exactly as it was delivered. Nothing rewrites it.
const link = extractLink(lastMessage(mail), "/verify-email");
expect(link.origin).toBe(install.origin);
const verified = await fetch(link, { redirect: "manual" });
// 4. Better Auth redirects to the Dashboard landing this deployment configured — never to the
// API origin, and never to anything a request supplied.
expect(verified.status).toBe(302);
const landing = new URL(verified.headers.get("location") ?? "");
expect(landing.origin).toBe(dashboardOrigin);
expect(landing.searchParams.get("ekklesia_auth")).toBe("verify-email");
expect(landing.searchParams.get("error")).toBeNull();
// No session was minted by following an emailed link.
expect(verified.headers.get("set-cookie")).toBeNull();
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
// 5. The same credentials now work, and the principal is a fully authenticated human.
const accepted = await signIn(install.origin, email, initialPassword);
expect(accepted.response.status).toBe(200);
expect(accepted.cookiePair).toBeDefined();
expect(await principalKind(install.origin, accepted.cookiePair)).toBe("authenticated");
});
it("refuses a malformed token and an expired token, each with its own reason", async () => {
const install = await installation("bad-token");
const email = "[email protected]";
await install.createIdentity({ email, name: "Expired", password: initialPassword });
const callback = encodeURIComponent(`${dashboardOrigin}/?ekklesia_auth=verify-email`);
for (const [token, reason] of [
["not-a-jwt", "INVALID_TOKEN"],
[forgeExpiredVerificationToken(email), "TOKEN_EXPIRED"],
] as const) {
const response = await fetch(
`${install.origin}/api/auth/verify-email?token=${encodeURIComponent(token)}&callbackURL=${callback}`,
{ redirect: "manual" },
);
expect(response.status).toBe(302);
const landing = new URL(response.headers.get("location") ?? "");
// The failure still lands on the host's own screen rather than a raw JSON error page.
expect(landing.origin).toBe(dashboardOrigin);
expect(landing.searchParams.get("error")).toBe(reason);
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
}
});
it("is idempotent when a link is followed twice, because the token is a signed claim", async () => {
const install = await installation("replay");
const email = "[email protected]";
await install.createIdentity({ email, name: "Replay", password: initialPassword });
await signIn(install.origin, email, initialPassword);
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
const first = await fetch(link, { redirect: "manual" });
const second = await fetch(link, { redirect: "manual" });
// Documented honestly (ADR 0038): the verification token is a signed JWT, not a consumed
// database row, so it is **not** single-use — it is valid until it expires. Replaying it is a
// no-op on an already-verified address and mints no session, which is what makes that safe.
// Mail scanners and prefetchers dereference links, so idempotence is a requirement here.
expect(first.status).toBe(302);
expect(second.status).toBe(302);
expect(second.headers.get("set-cookie")).toBeNull();
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
});
it("resends on request without revealing whether the address has an account", async () => {
const install = await installation("resend");
const email = "[email protected]";
await install.createIdentity({ email, name: "Resend", password: initialPassword });
const mail = install.mail as MailHarness;
const known = await post(install.origin, "/api/auth/send-verification-email", { email });
const unknown = await post(install.origin, "/api/auth/send-verification-email", {
email: "[email protected]",
});
const verifiedAlready = await post(install.origin, "/api/auth/send-verification-email", {
email: "[email protected]",
});
// Identical answers. The only difference anywhere is that a message was delivered, which the
// requester cannot observe.
expect(known.status).toBe(200);
expect(unknown.status).toBe(200);
expect(verifiedAlready.status).toBe(200);
expect(await known.clone().json()).toEqual(await unknown.clone().json());
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
});
});
describe("password recovery, end to end", () => {
it("replaces the password through a real emailed link and invalidates the old one", async () => {
const install = await installation("reset");
const email = "[email protected]";
await verifiedAccountFor(install, email);
const mail = install.mail as MailHarness;
const requested = await post(install.origin, "/api/auth/request-password-reset", { email });
expect(requested.status).toBe(200);
expect(mail.delivered).toHaveLength(1);
expect(lastMessage(mail).to).toEqual([email]);
expect(lastMessage(mail).data).toContain("Reset your Ekklesia password");
// Follow the emailed link. Better Auth validates the token and hands the browser on to the
// Dashboard landing with it attached — which is where the host's reset form picks it up.
const link = extractLink(lastMessage(mail), "/reset-password/");
expect(link.origin).toBe(install.origin);
const handoff = await fetch(link, { redirect: "manual" });
expect(handoff.status).toBe(302);
const landing = new URL(handoff.headers.get("location") ?? "");
expect(landing.origin).toBe(dashboardOrigin);
expect(landing.searchParams.get("ekklesia_auth")).toBe("reset-password");
const token = landing.searchParams.get("token") ?? "";
expect(token).not.toBe("");
const completed = await post(install.origin, "/api/auth/reset-password", {
newPassword: replacementPassword,
token,
});
expect(completed.status).toBe(200);
const oldCredential = await signIn(install.origin, email, initialPassword);
expect(oldCredential.response.status).toBe(401);
const newCredential = await signIn(install.origin, email, replacementPassword);
expect(newCredential.response.status).toBe(200);
expect(await principalKind(install.origin, newCredential.cookiePair)).toBe("authenticated");
});
it("consumes the reset token, so a captured link cannot be replayed", async () => {
const install = await installation("reset-replay");
const email = "[email protected]";
await verifiedAccountFor(install, email);
await post(install.origin, "/api/auth/request-password-reset", { email });
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const handoff = await fetch(link, { redirect: "manual" });
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
const first = await post(install.origin, "/api/auth/reset-password", {
newPassword: replacementPassword,
token,
});
const second = await post(install.origin, "/api/auth/reset-password", {
newPassword: "yet another different passphrase",
token,
});
// Unlike the verification token, this one is a database row Better Auth consumes.
expect(first.status).toBe(200);
expect(second.status).toBe(400);
expect(await second.json()).toMatchObject({ code: "INVALID_TOKEN" });
expect((await signIn(install.origin, email, replacementPassword)).response.status).toBe(200);
});
it("signs out every existing session for that account", async () => {
const install = await installation("reset-sessions");
const email = "[email protected]";
await verifiedAccountFor(install, email);
const before = await signIn(install.origin, email, initialPassword);
expect(await principalKind(install.origin, before.cookiePair)).toBe("authenticated");
await post(install.origin, "/api/auth/request-password-reset", { email });
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const handoff = await fetch(link, { redirect: "manual" });
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
await post(install.origin, "/api/auth/reset-password", {
newPassword: replacementPassword,
token,
});
// The whole point of recovery is that it works when the credential is already compromised, so
// an attacker's existing session must not survive it (`revokeSessionsOnPasswordReset`).
expect(await principalKind(install.origin, before.cookiePair)).toBe("anonymous");
});
it("enforces the identity system's own password policy, with no reset-specific rule", async () => {
const install = await installation("reset-policy");
const email = "[email protected]";
await verifiedAccountFor(install, email);
await post(install.origin, "/api/auth/request-password-reset", { email });
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const handoff = await fetch(link, { redirect: "manual" });
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
const tooShort = await post(install.origin, "/api/auth/reset-password", {
newPassword: "short",
token,
});
expect(tooShort.status).toBe(400);
expect(await tooShort.json()).toMatchObject({ code: "PASSWORD_TOO_SHORT" });
// The old password still works: a rejected reset changes nothing.
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(200);
});
it("answers a request for an address with no account exactly as it answers a real one", async () => {
const install = await installation("enumeration");
const email = "[email protected]";
await verifiedAccountFor(install, email);
const mail = install.mail as MailHarness;
const known = await post(install.origin, "/api/auth/request-password-reset", { email });
const unknown = await post(install.origin, "/api/auth/request-password-reset", {
email: "[email protected]",
});
expect(known.status).toBe(unknown.status);
expect(await known.clone().json()).toEqual(await unknown.clone().json());
expect(JSON.stringify(await unknown.json())).not.toContain("stranger");
// Only the real account received anything.
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
});
});
describe("recovery when the installation has no outbound email", () => {
it("answers identically and records the operational failure out of band", async () => {
const install = await installation("no-mail", { mail: false });
const email = "[email protected]";
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
const reset = await post(install.origin, "/api/auth/request-password-reset", { email });
const resend = await post(install.origin, "/api/auth/send-verification-email", { email });
// The user-facing answer must not change: "we could not send it" for a real address and a
// generic success for an unknown one is an enumeration oracle that appears when SMTP breaks.
expect(reset.status).toBe(200);
expect(resend.status).toBe(200);
// The operator still learns about it, with a sanitized code and no address or token.
expect(install.notificationFailures).toEqual([
{ kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" },
{ kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" },
]);
expect(JSON.stringify(install.notificationFailures)).not.toContain("[email protected]");
});
it("still starts, serves the API, and refuses an unverified sign-in", async () => {
const install = await installation("no-mail-runs", { mail: false });
const email = "[email protected]";
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
// Absent mail is a complete deployment (ADR 0037) and does **not** relax the verification
// policy — which is exactly why the initial owner is created already verified.
expect((await fetch(`${install.origin}/api/v1/principal`)).status).toBe(200);
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(403);
});
});
describe("recovery cannot be turned into a redirect or an account factory", () => {
it("refuses an attacker-supplied reset destination", async () => {
const install = await installation("redirect");
const email = "[email protected]";
await install.createIdentity({ email, name: "Redirect", password: initialPassword });
const external = await post(install.origin, "/api/auth/request-password-reset", {
email,
redirectTo: "https://evil.example.test/steal",
});
expect(external.status).toBe(403);
expect(await external.json()).toMatchObject({ code: "INVALID_REDIRECT_URL" });
expect((install.mail as MailHarness).delivered).toHaveLength(0);
});
it("refuses an attacker-supplied verification destination", async () => {
const install = await installation("redirect-verify");
for (const callbackURL of [
"https://evil.example.test",
"//evil.example.test",
"/\\evil.example.test",
"javascript:alert(1)",
]) {
const response = await fetch(
`${install.origin}/api/auth/verify-email?token=x&callbackURL=${encodeURIComponent(callbackURL)}`,
{ redirect: "manual" },
);
expect(response.status).toBe(403);
expect(response.headers.get("location")).toBeNull();
}
});
it("ignores a supplied destination even where one is accepted, because the link is rebuilt", async () => {
const install = await installation("redirect-ignored");
const email = "[email protected]";
await verifiedAccountFor(install, email);
// A *trusted* origin passes Better Auth's own check, so this is the case where the emailed
// link's destination would otherwise be caller-chosen. It is not: the message is built from
// configuration and the supplied value never reaches it.
await post(install.origin, "/api/auth/request-password-reset", {
email,
redirectTo: `${dashboardOrigin}/somewhere-else`,
});
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const callback = new URL(link.searchParams.get("callbackURL") ?? "");
expect(callback.origin).toBe(dashboardOrigin);
expect(callback.pathname).toBe("/");
expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password");
expect(link.href).not.toContain("somewhere-else");
});
it("keeps public signup disabled now that verification exists", async () => {
const install = await installation("signup");
const response = await post(install.origin, "/api/auth/sign-up/email", {
email: "[email protected]",
name: "Intruder",
password: initialPassword,
});
// Email verification is not a licence to self-register. Future accounts arrive through
// deliberate administration, which this pass does not build.
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ code: "EMAIL_PASSWORD_SIGN_UP_DISABLED" });
expect(await emailVerifiedFlag(install.url, "[email protected]")).toBeUndefined();
});
});
describe("managed devices are untouched by human email identity", () => {
it("leaves a paired device intact across a human password reset", async () => {
const install = await installation("devices");
const email = "[email protected]";
await verifiedAccountFor(install, email);
// A device is a row with a credential digest and no email, no password, and no verification
// state (ADR 0036). It is inserted directly here because pairing it through HTTP would prove
// something this test is not about.
const client = new Client({ connectionString: install.url });
await client.connect();
try {
await safeQuery(
client,
'insert into "ekklesia_core_device" ("name", "credential_hash") values ($1, $2)',
["Sanctuary display", "a".repeat(64)],
);
await post(install.origin, "/api/auth/request-password-reset", { email });
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const handoff = await fetch(link, { redirect: "manual" });
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
await post(install.origin, "/api/auth/reset-password", {
newPassword: replacementPassword,
token,
});
const devices = await safeQuery<{ credential_hash: string; revoked_at: Date | null }>(
client,
'select "credential_hash", "revoked_at" from "ekklesia_core_device"',
);
expect(devices).toEqual([{ credential_hash: "a".repeat(64), revoked_at: null }]);
// The device table has no email or verification concept for a human flow to reach.
const columns = await safeQuery<{ column_name: string }>(
client,
"select column_name from information_schema.columns where table_name = 'ekklesia_core_device'",
);
const names = columns.map((column) => column.column_name).join(" ");
expect(names).not.toContain("email");
expect(names).not.toContain("verified");
expect(names).not.toContain("password");
} finally {
await client.end().catch(() => undefined);
}
});
it("sends a device no mail, because a device has no address to send to", async () => {
const install = await installation("devices-mail");
await post(install.origin, "/api/auth/request-password-reset", {
email: "[email protected]",
});
// Nothing about device identity participates in either flow.
expect((install.mail as MailHarness).delivered).toHaveLength(0);
expect(install.notificationFailures).toEqual([]);
});
});
+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);
});
});
@@ -33,6 +33,7 @@ function environment(
const configuredInfrastructure = Object.freeze({ const configuredInfrastructure = Object.freeze({
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]", EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
EKKLESIA_SMTP_HOST: "smtp.example.org", EKKLESIA_SMTP_HOST: "smtp.example.org",
EKKLESIA_SMTP_PASSWORD: smtpPassword, EKKLESIA_SMTP_PASSWORD: smtpPassword,
EKKLESIA_SMTP_USERNAME: "[email protected]", EKKLESIA_SMTP_USERNAME: "[email protected]",
@@ -77,6 +78,9 @@ describe("host configuration parity between startApi and Doctor", () => {
expect(apiView(env).mail).toEqual(doctorView(env).mail); expect(apiView(env).mail).toEqual(doctorView(env).mail);
expect(apiView(env).mail).toMatchObject({ expect(apiView(env).mail).toMatchObject({
// The SMTP client identity is part of that equivalence: a relay that refuses the greeting
// must fail in Doctor for the same reason it fails at runtime.
heloName: "mail.example.org",
host: "smtp.example.org", host: "smtp.example.org",
port: 587, port: 587,
security: "starttls", security: "starttls",
@@ -123,6 +127,10 @@ describe("host configuration parity between startApi and Doctor", () => {
"MAIL_USERNAME_WITHOUT_PASSWORD", "MAIL_USERNAME_WITHOUT_PASSWORD",
], ],
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"], [{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
[
{ ...configuredInfrastructure, EKKLESIA_SMTP_HELO_NAME: "localhost" },
"MAIL_HELO_NAME_INVALID",
],
] as const) { ] as const) {
const env = environment(overrides); const env = environment(overrides);
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code })); expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
+165 -4
View File
@@ -170,9 +170,13 @@ function bootstrapAgainst(url: string): BootstrapHandles {
}; };
} }
/** The production posture: public signup disabled, exactly as a real deployment must run. */ /**
async function startApiWithoutSignup(url: string): Promise<RunningApi> { * The production posture: public signup disabled, exactly as a real deployment must run — and, just
const config = createApiRuntimeConfig( * as deliberately, **no outbound email configured at all**. A fresh installation has no relay yet,
* which is the whole reason the initial owner is created already verified (ADR 0038).
*/
function ownerApiConfig(url: string): ReturnType<typeof createApiRuntimeConfig> {
return createApiRuntimeConfig(
{ {
databaseUrl: url, databaseUrl: url,
host: "127.0.0.1", host: "127.0.0.1",
@@ -186,7 +190,10 @@ async function startApiWithoutSignup(url: string): Promise<RunningApi> {
}, },
{ environment: "development" }, { environment: "development" },
); );
return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } }); }
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
return startApi(ownerApiConfig(url), { rateLimitOverride: { max: 1000, window: 1 } });
} }
function apiOrigin(running: RunningApi): string { function apiOrigin(running: RunningApi): string {
@@ -270,6 +277,22 @@ async function countRows(url: string, table: string): Promise<number> {
} }
} }
/** Better Auth's own `emailVerified` column — the same value its `/verify-email` endpoint writes. */
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
const client = new Client({ connectionString: url });
await client.connect();
try {
const rows = await safeQuery<{ email_verified: boolean }>(
client,
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
[email.trim().toLowerCase()],
);
return rows[0]?.email_verified;
} finally {
await client.end().catch(() => undefined);
}
}
afterAll(async () => { afterAll(async () => {
const admin = await openAdministrativeClient().catch(() => undefined); const admin = await openAdministrativeClient().catch(() => undefined);
if (!admin) { if (!admin) {
@@ -685,3 +708,141 @@ describe.sequential("initial platform-owner bootstrap", () => {
}); });
}); });
}); });
describe.sequential("initial platform-owner bootstrap and email verification", () => {
/**
* The trusted-bootstrap verification exception (ADR 0038).
*
* A verified address became a precondition for holding a session in Phase 7C Pass 2. The initial
* owner is created on a freshly provisioned server, by an operator holding the database
* credentials, at a moment when SMTP may not exist — so requiring an email round trip here would
* produce a working installation nobody can ever sign in to. These scenarios exist to prove that
* the exception is real, that it is narrow, and that mail remains genuinely absent throughout.
*/
it("creates the owner already verified, proven in the database and in the browser", async () => {
const url = await freshInstallation("verifiedowner");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
await bootstrap.run(email);
} finally {
await bootstrap.close();
}
// Database state: the exact column Better Auth's own verification endpoint writes, so the row
// is indistinguishable from one that verified normally.
expect(await emailVerifiedFlag(url, email)).toBe(true);
// ...and the browser-facing consequence, which is the only thing that actually matters.
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
const session = await signIn(origin, email, ownerPassword);
expect(session.response.status).toBe(200);
expect(session.cookiePair).toBeDefined();
} finally {
await api.shutdown();
}
});
it("needs no outbound email configured, at bootstrap or at sign-in", async () => {
const url = await freshInstallation("nomailowner");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
// No SMTP host, no relay, no network: `createBootstrapIdentityCreator` constructs its own
// Better Auth instance with no `emailVerification` configuration at all.
await bootstrap.run(email);
} finally {
await bootstrap.close();
}
const api = await startApiWithoutSignup(url);
try {
// The API this suite starts configures no mail either, so nothing in this scenario could have
// sent or received a message.
expect(ownerApiConfig(url).mail).toBeUndefined();
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
} finally {
await api.shutdown();
}
});
it("marks an adopted identity verified too, so bootstrap never yields an owner who cannot sign in", async () => {
const url = await freshInstallation("adoptverified");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
// An identity that exists before bootstrap runs — created here through the same trusted creator,
// which always writes `emailVerified: false`.
const first = bootstrapAgainst(url);
try {
await first.run(email);
} finally {
await first.close();
}
const client = new Client({ connectionString: url });
await client.connect();
try {
await safeQuery(client, 'update "ekklesia_core_user" set "email_verified" = false');
await safeQuery(client, 'delete from "ekklesia_core_platform_owner_bootstrap"');
await safeQuery(client, 'delete from "ekklesia_core_platform_owner"');
} finally {
await client.end().catch(() => undefined);
}
expect(await emailVerifiedFlag(url, email)).toBe(false);
const second = bootstrapAgainst(url);
let adopted: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
try {
adopted = await second.run(email);
} finally {
await second.close();
}
expect(adopted.identity).toBe("adopted");
expect(await emailVerifiedFlag(url, email)).toBe(true);
});
it("verifies only the owner, leaving every other account subject to the normal policy", async () => {
const url = await freshInstallation("narrowexception");
const ownerEmail = `owner-${randomBytes(4).toString("hex")}@example.test`;
const otherEmail = `member-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
await bootstrap.run(ownerEmail);
// A second identity created through the identical trusted creator, but never bootstrapped.
const identity = createIdentityDrizzleDatabase(url, {});
try {
await createBootstrapIdentityCreator(identity.db)({
email: otherEmail,
name: "Ordinary Member",
password: ownerPassword,
});
} finally {
await identity.close().catch(() => undefined);
}
} finally {
await bootstrap.close();
}
expect(await emailVerifiedFlag(url, ownerEmail)).toBe(true);
expect(await emailVerifiedFlag(url, otherEmail)).toBe(false);
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
expect((await signIn(origin, ownerEmail, ownerPassword)).response.status).toBe(200);
// The exception did not widen: an ordinary account still has to verify, and on this
// mail-less installation it simply cannot sign in yet.
const refused = await signIn(origin, otherEmail, ownerPassword);
expect(refused.response.status).toBe(403);
expect(await refused.response.json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
expect(refused.cookiePair).toBeUndefined();
} finally {
await api.shutdown();
}
});
});
+73 -2
View File
@@ -9,11 +9,21 @@ const drizzleAdapterMock = vi.hoisted(() => vi.fn());
const serveMock = vi.hoisted(() => vi.fn()); const serveMock = vi.hoisted(() => vi.fn());
const processOnSpy = vi.hoisted(() => vi.fn()); const processOnSpy = vi.hoisted(() => vi.fn());
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn()); const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor })); vi.mock("pg", () => ({ Pool: poolConstructor }));
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock })); vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock })); 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) => { vi.mock("../src/display-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>(); const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock }; return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
@@ -35,7 +45,13 @@ import {
import { PermissionRegistryError } from "@ekklesia/core/authorization"; import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { createApiRuntimeConfig } from "../src/config.js"; import { createApiRuntimeConfig } from "../src/config.js";
import { DashboardHostAssetError } from "../src/dashboard-host-assets.js";
import { DisplayHostAssetError } from "../src/display-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"; import { installShutdownSignalHandlers, startApi } from "../src/server.js";
function validConfig() { function validConfig() {
@@ -88,7 +104,7 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -254,12 +270,13 @@ function fakeDiscoveredModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions, permissions,
version: "0.1.0", version: "0.1.0",
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
@@ -446,6 +463,36 @@ describe("Ekklesia API shutdown lifecycle", () => {
expect(poolEnd).toHaveBeenCalledTimes(1); 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 () => { it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
@@ -1021,6 +1068,30 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
expect(serveMock).not.toHaveBeenCalled(); 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 () => { it("does not read display-host assets when the eligible set declares no display", async () => {
const modulesDirectory = await createTemporaryModulesDirectory(); const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder(); 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_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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 { import {
createFakeModuleSecretSource, createFakeModuleSecretSource,
createInMemoryModuleConfigurationStore, createInMemoryModuleConfigurationStore,
@@ -106,7 +108,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
configuration: fixture.configuration ?? [], configuration: fixture.configuration ?? [],
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -189,6 +191,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
store: authorizationStore, store: authorizationStore,
}, },
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleConfiguration: { moduleConfiguration: {
bootSettings, bootSettings,
discoveredModules: modules, discoveredModules: modules,
@@ -224,6 +224,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -253,6 +254,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"db-facade:0000_initial", "db-facade:0000_initial",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
@@ -319,6 +321,27 @@ async function signUp(origin: string, email: string, password: string): Promise<
return { userId: body.user.id }; return { userId: body.user.id };
} }
/**
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs.
*
* A verified address is a precondition for holding a session (ADR 0038), and this suite is about
* module database access rather than identity, so it completes that step directly rather than
* standing up an SMTP server. That the policy is genuinely enforced is asserted in
* `email-verification.integration.ts`.
*/
async function markEmailVerified(url: string, email: string): Promise<void> {
const client = new Client({ connectionString: url });
await client.connect();
try {
await client.query(
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
[email.trim().toLowerCase()],
);
} finally {
await client.end().catch(() => undefined);
}
}
async function signIn(origin: string, email: string, password: string): Promise<string> { async function signIn(origin: string, email: string, password: string): Promise<string> {
const response = await fetch(`${origin}/api/auth/sign-in/email`, { const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
@@ -507,6 +530,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" }); expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
const signedUp = await signUp(origin, "[email protected]", "correct horse battery"); const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
await markEmailVerified(database.url, "[email protected]");
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery"); ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
expect(signedUp.userId).toBeDefined(); expect(signedUp.userId).toBeDefined();
}); });
+73 -5
View File
@@ -28,6 +28,7 @@ import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
import { import {
DISPLAY_HOST_SCRIPT_PATH, DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_PATH,
ModuleDisplayConfigurationError, ModuleDisplayConfigurationError,
moduleDisplayDescriptorUrl, moduleDisplayDescriptorUrl,
moduleDisplayPageUrl, moduleDisplayPageUrl,
@@ -37,6 +38,8 @@ import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js"; import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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 { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { import {
createInMemoryModuleLifecycleStore, createInMemoryModuleLifecycleStore,
@@ -51,6 +54,7 @@ const displayPermission = "announcements.display.view";
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets( const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`, `<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
"globalThis.__ekklesiaDisplayHostLoaded = true;\n", "globalThis.__ekklesiaDisplayHostLoaded = true;\n",
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
); );
const temporaryDirectories: string[] = []; const temporaryDirectories: string[] = [];
@@ -101,7 +105,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
: {}), : {}),
}, },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: fixture.display permissions: fixture.display
? [ ? [
@@ -196,6 +200,8 @@ async function install(options: InstallOptions): Promise<Installation> {
store: fakeAuthorizationStore(), store: fakeAuthorizationStore(),
}, },
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false ...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
? { displayHostAssets: options.assets ?? displayHostAssets } ? { displayHostAssets: options.assets ?? displayHostAssets }
: {}), : {}),
@@ -266,11 +272,13 @@ describe("module display routes — authorization and descriptor", () => {
expect(await descriptor.json()).toEqual({ expect(await descriptor.json()).toEqual({
viewer: { kind: "authenticated", userId: viewerId }, viewer: { kind: "authenticated", userId: viewerId },
module: { module: {
contributions: [],
api: { baseUrl: moduleApiBaseUrl("announcements") }, api: { baseUrl: moduleApiBaseUrl("announcements") },
display: { display: {
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }), url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
}, },
id: "announcements", id: "announcements",
page: { url: moduleDisplayPageUrl("announcements") },
permissions: [displayPermission], permissions: [displayPermission],
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") }, realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
version: "1.4.2", version: "1.4.2",
@@ -348,9 +356,14 @@ describe("module display routes — role and lifecycle separation", () => {
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200); expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200); expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
expect( expect(
((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] }) (
.modules, (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
).toHaveLength(1); modules: Array<{ display: { url: string } | null }>;
}
).modules,
).toEqual([
expect.objectContaining({ display: { url: moduleDisplayPageUrl("announcements") } }),
]);
}); });
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => { it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
@@ -476,7 +489,11 @@ describe("module display host asset publication", () => {
}); });
it("snapshots injected assets and rejects a validator that does not describe its script", async () => { it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
const source = createDisplayHostAssets("<html>display host</html>", "export const host = 1;\n"); const source = createDisplayHostAssets(
"<html>display host</html>",
"export const host = 1;\n",
"export const worker = 1;\n",
);
const mutable = { ...source }; const mutable = { ...source };
const { app } = await install({ assets: mutable, display: true }); const { app } = await install({ assets: mutable, display: true });
mutable.html = "<html>mutated secret</html>"; mutable.html = "<html>mutated secret</html>";
@@ -497,4 +514,55 @@ describe("module display host asset publication", () => {
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404); expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
}); });
it("serves the offline service worker from the path whose directory is its scope", async () => {
const { app } = await install({ display: true });
const response = await app.request(DISPLAY_SERVICE_WORKER_PATH);
// `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing
// else. The Dashboard is never controlled by it (ADR 0043).
expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js");
expect(response.status).toBe(200);
expect(await response.text()).toBe(displayHostAssets.serviceWorker);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
// `no-cache` with a strong validator, exactly like the host script: a browser re-checks the
// worker on its own schedule, which is how a screen avoids being trapped on an obsolete one.
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("revalidates the service worker with its own validator, never the host script's", async () => {
const { app } = await install({ display: true });
const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
headers: { "if-none-match": displayHostAssets.serviceWorkerEtag },
});
expect(notModified.status).toBe(304);
const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
headers: { "if-none-match": displayHostAssets.scriptEtag },
});
expect(wrongValidator.status).toBe(200);
});
it("does not publish the service worker when no eligible display uses it", async () => {
const { app } = await install({ ui: true, withAssets: false });
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404);
});
it("cannot be reached through a neighbouring or request-selected path", async () => {
const { app } = await install({ display: true });
for (const candidate of [
`${DISPLAY_SERVICE_WORKER_PATH}.map`,
"/display/sw.js/",
"/sw.js",
"/assets/ekklesia-display/sw.js",
]) {
expect((await app.request(candidate)).status, candidate).toBe(404);
}
});
}); });
+383 -4
View File
@@ -23,6 +23,8 @@ import {
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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 { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { import {
canonicalizeVaryHeader, canonicalizeVaryHeader,
@@ -58,14 +60,17 @@ function fakeStore(): AuthorizationStore {
}; };
} }
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the /** Defaults `maxBodyBytes` and `bodyMode` for tests that are not themselves exercising the
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not * body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
* each need to restate an unrelated body-size choice; the dedicated "module route body limits" * each need to restate an unrelated body-size choice; the dedicated "module route body limits"
* suite further down overrides it explicitly to prove the contract itself. */ * suite further down overrides them explicitly to prove the contract itself. */
function moduleRoute( function moduleRoute(
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number }, fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
maxBodyBytes?: number;
},
): RegisteredModuleHttpRoute { ): RegisteredModuleHttpRoute {
return Object.freeze({ maxBodyBytes: 1024, ...fields }); return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
} }
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting, /** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
@@ -120,6 +125,8 @@ function buildModuleApp(options: {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime, runtime,
trustedOrigins: options.trustedOrigins ?? [trustedOrigin], trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
}); });
@@ -1634,3 +1641,375 @@ describe("module route overlap can never reach mounting", () => {
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true); expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
}); });
}); });
/**
* Explicit, bounded streaming uploads (ADR 0042).
*
* The distinction being proven throughout is *when* the platform knows a body is too large, and what
* it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at
* most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it,
* holding nothing" — and the `413` still wins, even when the handler tried to answer anyway.
*/
describe("module route streamed body limits", () => {
const oneMebibyte = 1024 * 1024;
/** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the
* chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */
function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {
let sent = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (sent >= size) {
controller.close();
return;
}
const next = Math.min(chunkSize, size - sent);
sent += next;
controller.enqueue(new Uint8Array(next).fill(97));
},
});
}
function streamingApp(options: {
readonly handler: ModuleRouteHandler;
readonly maxBodyBytes: number;
readonly evaluator?: AuthorizationEvaluator;
}) {
return buildModuleApp({
...(options.evaluator ? { evaluator: options.evaluator } : {}),
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
bodyMode: "streamed",
handler: options.handler,
maxBodyBytes: options.maxBodyBytes,
method: "POST",
moduleId: "calendar",
path: "/uploads",
permission: writePermission,
}),
],
});
}
/** Counts what the handler actually received, which is the only honest way to tell "streamed" from
* "buffered then handed over". */
async function countingHandler(received: { bytes: number }): Promise<ModuleRouteHandler> {
return async (request) => {
const body = request.body;
if (!body) {
return Response.json({ bytes: 0 });
}
const reader = body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
received.bytes += value.byteLength;
}
return Response.json({ bytes: received.bytes });
};
}
it("accepts a body of exactly the declared limit", async () => {
const received = { bytes: 0 };
const { app } = streamingApp({
handler: await countingHandler(received),
maxBodyBytes: oneMebibyte,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(oneMebibyte),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ bytes: oneMebibyte });
});
it("rejects a declared Content-Length over the limit before the handler runs", async () => {
const received = { bytes: 0 };
const counting = await countingHandler(received);
const handler = vi.fn<ModuleRouteHandler>(counting);
const { app } = streamingApp({ handler, maxBodyBytes: 1024 });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(2048, 16),
duplex: "half",
headers: { "content-length": String(1025), origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
// The cheap rejection: the declared size alone decided it, so no handler work happened and no
// byte of the upload reached module code.
expect(handler).not.toHaveBeenCalled();
expect(received.bytes).toBe(0);
});
it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => {
// A misleading `Content-Length` buys nothing: the running byte count is what bounds the request,
// so a header claiming 8 bytes cannot smuggle a megabyte past the limit.
const received = { bytes: 0 };
const { app } = streamingApp({
handler: await countingHandler(received),
maxBodyBytes: 64,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096, 32),
duplex: "half",
headers: { "content-length": "8", origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
// It stopped promptly rather than draining the whole upload first.
expect(received.bytes).toBeLessThanOrEqual(96);
});
it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => {
// A limit a handler can swallow is not a limit.
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
try {
await request.arrayBuffer();
} catch {
// Deliberately ignored, exactly as a careless module might.
}
return Response.json({ ok: "handler won" });
});
const { app } = streamingApp({ handler, maxBodyBytes: 64 });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096, 32),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(handler).toHaveBeenCalledTimes(1);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
it("never buffers the body: the handler sees the first bytes before the last are sent", async () => {
let released: (() => void) | undefined;
const finished = new Promise<void>((resolve) => {
released = resolve;
});
let firstChunkSeen = false;
let secondChunkQueued = false;
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
if (!secondChunkQueued) {
secondChunkQueued = true;
controller.enqueue(new Uint8Array(16).fill(97));
return;
}
// Only produce the rest once the handler has demonstrably consumed the first chunk. A
// buffering platform would deadlock here rather than reach the assertion below.
await finished;
controller.close();
},
});
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
const reader = (request.body as ReadableStream<Uint8Array>).getReader();
const first = await reader.read();
firstChunkSeen = !first.done;
released?.();
for (;;) {
const next = await reader.read();
if (next.done) {
break;
}
}
return Response.json({ ok: true });
});
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(firstChunkSeen).toBe(true);
expect(response.status).toBe(200);
});
it("releases an aborted upload without taking the platform down", async () => {
const aborted = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(16).fill(97));
controller.error(new Error("client went away"));
},
});
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
await request.arrayBuffer();
return Response.json({ ok: true });
});
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: aborted,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
// An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit.
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
});
it("releases a body the handler never read", async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
cancel() {
cancelled = true;
},
pull(controller) {
controller.enqueue(new Uint8Array(16).fill(97));
},
});
const { app } = streamingApp({
handler: async () => Response.json({ ok: true }),
maxBodyBytes: oneMebibyte,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(200);
expect(cancelled).toBe(true);
});
it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: false }),
};
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
evaluator,
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
routes: [
moduleRoute({
bodyMode: "streamed",
handler,
maxBodyBytes: 64,
method: "POST",
moduleId: "calendar",
path: "/uploads",
permission: writePermission,
}),
],
});
const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", {
body: "a".repeat(65),
headers: { "content-length": "65", origin: trustedOrigin },
method: "POST",
});
expect(declaredOversize.status).toBe(413);
expect(evaluator.authorize).not.toHaveBeenCalled();
// A within-limit body from a denied caller is refused by authorization, and the handler — the
// only thing that ever reads a byte in streamed mode — never runs.
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(32, 8),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(handler).not.toHaveBeenCalled();
});
it("still rejects an untrusted Origin before any body handling", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096),
duplex: "half",
headers: { origin: untrustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(handler).not.toHaveBeenCalled();
});
it("keeps every rejection sanitized, naming no limit, size, or body content", async () => {
const { app } = streamingApp({
handler: async () => Response.json({ ok: true }),
maxBodyBytes: 64,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: "secret-payload-".repeat(64),
headers: { "content-length": String(15 * 64), origin: trustedOrigin },
method: "POST",
});
const body = await response.text();
expect(response.status).toBe(413);
expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}');
expect(body).not.toContain("secret-payload");
expect(body).not.toContain("64");
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("still materializes a buffered body before the handler runs", async () => {
// The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the
// body is already there and already within its declared allowance.
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
const text = await request.text();
return Response.json({ length: text.length });
});
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
bodyMode: "buffered",
handler,
maxBodyBytes: 128,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const withinLimit = await app.request("/api/v1/modules/calendar/events", {
body: chunkedBody(128, 16),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(withinLimit.status).toBe(200);
expect(await withinLimit.json()).toEqual({ length: 128 });
const overLimit = await app.request("/api/v1/modules/calendar/events", {
body: chunkedBody(129, 16),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(overLimit.status).toBe(413);
// A buffered route's handler never sees an over-limit request at all.
expect(handler).toHaveBeenCalledTimes(1);
});
});
@@ -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_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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 { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js"; import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
@@ -77,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}), ...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -171,6 +173,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
store: authorizationStore, store: authorizationStore,
}, },
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
moduleLifecycle: { moduleLifecycle: {
+1 -1
View File
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id: "announcements", id: "announcements",
manifestVersion: 6, manifestVersion: 7,
name: "Announcements fixture", name: "Announcements fixture",
permissions: [ permissions: [
{ {
+58 -15
View File
@@ -20,7 +20,13 @@ import {
discoverModules, discoverModules,
startDiscoveredModuleRuntime, startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host"; import {
loadModuleUi,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
} from "@ekklesia/module-ui-host";
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
import { Window } from "happy-dom";
import { import {
bootstrapDisplayHost, bootstrapDisplayHost,
bootstrapHost, bootstrapHost,
@@ -30,6 +36,8 @@ import {
} from "@ekklesia/shell"; } from "@ekklesia/shell";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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 { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js"; import { createDisplayHostAssets } from "../src/display-host-assets.js";
import { import {
@@ -121,7 +129,7 @@ async function createFixtureModule(
: {}), : {}),
}, },
id, id,
manifestVersion: 6, manifestVersion: 7,
name, name,
permissions: [ permissions: [
{ {
@@ -165,6 +173,7 @@ async function createFixtureModule(
await writeFile( await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"), path.join(moduleDirectory, "dist/ui/index.js"),
`export default { `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: "0.1.0", version: "0.1.0",
async mount(container, context) { async mount(container, context) {
@@ -184,6 +193,7 @@ async function createFixtureModule(
await writeFile( await writeFile(
path.join(moduleDirectory, "dist/display/index.js"), path.join(moduleDirectory, "dist/display/index.js"),
`export default { `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: "0.1.0", version: "0.1.0",
async mount(container, context) { async mount(container, context) {
@@ -270,6 +280,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
displayHostAssets: createDisplayHostAssets( displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>', '<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
"export const displayHost = true;\n", "export const displayHost = true;\n",
"export const displayWorker = true;\n",
), ),
}), }),
moduleLifecycle: { moduleLifecycle: {
@@ -279,6 +290,8 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime, runtime,
trustedOrigins: ["https://dashboard.example.test"], trustedOrigins: ["https://dashboard.example.test"],
}); });
@@ -310,10 +323,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
return `data:text/javascript;base64,${bytes.toString("base64")}`; return `data:text/javascript;base64,${bytes.toString("base64")}`;
} }
/** The loader hands the container straight to the module and never inspects it, so a plain sink is /**
* enough and no DOM implementation is needed in this Node-environment suite. */ * A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
function containerSink(): { textContent: string } { * test environment.
return { textContent: "" }; *
* Since ADR 0040 the generic loader builds an isolated Shadow DOM root inside whatever container it
* is given, so a plain object sink is no longer a faithful stand-in — and the isolation is exactly
* what an end-to-end proof should exercise rather than stub out. Installing happy-dom as the
* *environment* is not an option here: it replaces the module runner's resolution, and this suite
* dynamically imports real module server entrypoints from temporary directories, which then fail to
* resolve. Constructing one `Window` gives the DOM these few assertions need and leaves every global
* this server-side suite depends on exactly as Node provides it.
*/
function containerSink(): HostContainer {
return new Window().document.createElement("div") as unknown as HostContainer;
}
interface HostContainer {
readonly children: { readonly length: number };
querySelector(selectors: string): HostContainer | null;
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
}
/** What the mounted artifact rendered, read through its open isolated root. */
function mountedText(container: HostContainer): string {
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
return (
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
""
);
} }
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"]; type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
@@ -340,6 +378,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
// 2. The URLs came from the server, not from anything the browser composed. // 2. The URLs came from the server, not from anything the browser composed.
expect(descriptor).toEqual({ expect(descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example", apiBaseUrl: "/api/v1/modules/example",
contributions: [],
id: "example", id: "example",
permissions: ["example.records.read"], permissions: ["example.records.read"],
realtimeBaseUrl: "/api/v1/module-realtime/example", realtimeBaseUrl: "/api/v1/module-realtime/example",
@@ -365,13 +404,13 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}); });
// 5. The module rendered, having reached its own backend through the host. // 5. The module rendered, having reached its own backend through the host.
expect(container.textContent).toBe("example:200:2"); expect(mountedText(container)).toBe("example:200:2");
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix. // 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]); expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
// 7. And it tears down cleanly. // 7. And it tears down cleanly.
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(mountedText(container)).toBe("");
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
} finally { } finally {
await harness.stop(); await harness.stop();
@@ -403,6 +442,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
} }
expect(bootstrap.descriptor).toEqual({ expect(bootstrap.descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example", apiBaseUrl: "/api/v1/modules/example",
contributions: [],
displayPageUrl: "/display/example",
id: "example", id: "example",
permissions: ["example.records.read", "example.display.view"], permissions: ["example.records.read", "example.display.view"],
realtimeBaseUrl: "/api/v1/module-realtime/example", realtimeBaseUrl: "/api/v1/module-realtime/example",
@@ -418,8 +459,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
); );
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl); const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
// 4. The generic loader receives the unchanged five-member browser context and mounts the // 4. The generic loader receives the host-owned browser context, including the canonical
// display artifact independently of the Dashboard artifact. // display page, and mounts the display artifact independently of the Dashboard artifact.
const context = createModuleUiContext({ const context = createModuleUiContext({
config: harness.config, config: harness.config,
descriptor: bootstrap.descriptor, descriptor: bootstrap.descriptor,
@@ -427,6 +468,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}); });
expect(Object.keys(context).sort()).toEqual([ expect(Object.keys(context).sort()).toEqual([
"api", "api",
"contributions",
"display",
"moduleId", "moduleId",
"permissions", "permissions",
"principal", "principal",
@@ -443,10 +486,10 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}, },
}); });
expect(container.textContent).toBe("example:display:200:2"); expect(mountedText(container)).toBe("example:display:200:2");
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]); expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(mountedText(container)).toBe("");
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
} finally { } finally {
await harness.stop(); await harness.stop();
@@ -514,7 +557,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}); });
// Hiding a control is a usability improvement: the module simply did not ask. // Hiding a control is a usability improvement: the module simply did not ask.
expect(container.textContent).toBe("example:denied"); expect(mountedText(container)).toBe("example:denied");
expect(harness.moduleApiCalls).toEqual([]); expect(harness.moduleApiCalls).toEqual([]);
} finally { } finally {
await harness.stop(); await harness.stop();
@@ -593,7 +636,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
version: groupedEntry.descriptor.version, version: groupedEntry.descriptor.version,
}, },
}); });
expect(container.textContent).toBe("example:200:2"); expect(mountedText(container)).toBe("example:200:2");
await mounted.unmount(); await mounted.unmount();
// Now move it somewhere else entirely. // Now move it somewhere else entirely.
@@ -645,7 +688,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
version: movedEntry.descriptor.version, version: movedEntry.descriptor.version,
}, },
}); });
expect(movedContainer.textContent).toBe("example:200:2"); expect(mountedText(movedContainer)).toBe("example:200:2");
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([ expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
"GET /api/v1/modules/example/records", "GET /api/v1/modules/example/records",
]); ]);
+113 -1
View File
@@ -25,6 +25,8 @@ import {
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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 { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js"; import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js"; import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
@@ -59,6 +61,12 @@ interface ModuleFixture {
readonly id: string; readonly id: string;
readonly baseline: "authenticated" | "public" | "restricted"; readonly baseline: "authenticated" | "public" | "restricted";
}>; }>;
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
readonly contributions?: readonly {
readonly localName: string;
readonly capability: string;
readonly capabilityRange: string;
}[];
} }
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> { async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
@@ -82,8 +90,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
server: "./dist/server/index.js", server: "./dist/server/index.js",
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}), ...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
}, },
capabilities: fixture.capabilities ?? [],
contributions: (fixture.contributions ?? []).map((contribution) => ({
artifact: `./dist/contributions/${contribution.localName}/index.js`,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: `${fixture.id}.${contribution.localName}`,
version: "1.0.0",
})),
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({ permissions: (fixture.permissions ?? []).map((permission) => ({
...permission, ...permission,
@@ -103,6 +119,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`, `export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
); );
} }
for (const contribution of fixture.contributions ?? []) {
const contributionDirectory = path.join(
moduleDirectory,
"dist/contributions",
contribution.localName,
);
await mkdir(contributionDirectory, { recursive: true });
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};\n");
}
} }
interface InstallOptions { interface InstallOptions {
@@ -185,6 +210,8 @@ async function install(options: InstallOptions): Promise<Installation> {
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
}); });
@@ -219,7 +246,9 @@ describe("module UI inventory — what it lists", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(modules).toEqual([ expect(modules).toEqual([
{ {
contributions: [],
api: { baseUrl: "/api/v1/modules/example" }, api: { baseUrl: "/api/v1/modules/example" },
display: null,
id: "example", id: "example",
permissions: [], permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/example" }, realtime: { baseUrl: "/api/v1/module-realtime/example" },
@@ -315,6 +344,8 @@ describe("module UI inventory — what it lists", () => {
// The exact key set, so a future field cannot be added without a deliberate decision here. // The exact key set, so a future field cannot be added without a deliberate decision here.
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([ expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
"api", "api",
"contributions",
"display",
"id", "id",
"permissions", "permissions",
"realtime", "realtime",
@@ -520,3 +551,84 @@ describe("module UI inventory — it is not a module management API", () => {
} }
}); });
}); });
/**
* The inventory is how a consuming module learns what was contributed to its own capabilities
* (ADR 0041). It is the *only* way: a module never queries a registry and never builds a URL.
*/
describe("module UI inventory — contributions", () => {
const signage = {
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
id: "signage",
permissions: [],
withUi: true,
};
const announcements = (capabilityRange = "^1.0.0") => ({
contributions: [
{ capability: "signage.slide-renderer", capabilityRange, localName: "signage-slide" },
],
id: "announcements",
permissions: [],
withUi: true,
});
it("gives the providing module the contribution and its server-produced URL", async () => {
const { app } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
const consumer = modules.find((module) => module.id === "signage");
expect(consumer?.contributions).toEqual([
{
capability: "signage.slide-renderer",
id: "announcements.signage-slide",
url: "/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
version: "1.0.0",
},
]);
});
it("gives the contributing module nothing: it provides no capability that was targeted", async () => {
const { app } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.find((module) => module.id === "announcements")?.contributions).toEqual([]);
});
it("withholds a contribution whose provider is outside its declared range", async () => {
const { app } = await install({
fixtures: [signage, announcements("^2.0.0")],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.find((module) => module.id === "signage")?.contributions).toEqual([]);
});
it("carries no filesystem path or domain vocabulary in a contribution descriptor", async () => {
const { app, modulesDirectory } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { body, modules } = await inventoryOf(app);
const consumer = modules.find((module) => module.id === "signage");
const contributions = (consumer?.contributions ?? []) as ReadonlyArray<Record<string, unknown>>;
expect(body).not.toContain(modulesDirectory);
expect(Object.keys(contributions[0] ?? {}).sort()).toEqual([
"capability",
"id",
"url",
"version",
]);
});
});
+173 -10
View File
@@ -16,15 +16,20 @@ import {
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host"; import { loadModuleUi } from "@ekklesia/module-ui-host";
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
import { Window } from "happy-dom";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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 { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js"; import { createDisplayHostAssets } from "../src/display-host-assets.js";
import { import {
MODULE_UI_ASSET_PREFIX, MODULE_UI_ASSET_PREFIX,
MODULE_UI_CACHE_CONTROL, MODULE_UI_CACHE_CONTROL,
MODULE_UI_CONTENT_TYPE, MODULE_UI_CONTENT_TYPE,
moduleContributionArtifactUrl,
moduleDisplayUiArtifactUrl, moduleDisplayUiArtifactUrl,
moduleUiArtifactUrl, moduleUiArtifactUrl,
} from "../src/module-ui-routes.js"; } from "../src/module-ui-routes.js";
@@ -59,6 +64,7 @@ async function createModulesDirectory(): Promise<string> {
* sequence whose exact publication and loader-compatibility can be asserted. */ * sequence whose exact publication and loader-compatibility can be asserted. */
function artifactSource(id: string, version: string): string { function artifactSource(id: string, version: string): string {
return `export default { return `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: ${JSON.stringify(version)}, version: ${JSON.stringify(version)},
mount(container, context) { mount(container, context) {
@@ -83,6 +89,15 @@ interface ModuleFixture {
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against * chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
* files that genuinely exist on disk rather than against hypothetical ones. */ * files that genuinely exist on disk rather than against hypothetical ones. */
readonly extraFiles?: Readonly<Record<string, string>>; readonly extraFiles?: Readonly<Record<string, string>>;
/** Capabilities this module provides — what another module's contributions target. */
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
/** Browser renderers this module contributes to another module's capability. */
readonly contributions?: readonly {
readonly localName: string;
readonly capability: string;
readonly capabilityRange: string;
readonly source: string;
}[];
} }
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> { async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
@@ -115,8 +130,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
}, },
}), }),
}, },
capabilities: fixture.capabilities ?? [],
contributions: (fixture.contributions ?? []).map((contribution) => ({
artifact: `./dist/contributions/${contribution.localName}/index.js`,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: `${fixture.id}.${contribution.localName}`,
version: "1.0.0",
})),
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: permissions:
fixture.display === undefined fixture.display === undefined
@@ -139,6 +162,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui); await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
} }
for (const contribution of fixture.contributions ?? []) {
const contributionDirectory = path.join(
moduleDirectory,
"dist/contributions",
contribution.localName,
);
await mkdir(contributionDirectory, { recursive: true });
await writeFile(path.join(contributionDirectory, "index.js"), contribution.source);
await writeFile(path.join(contributionDirectory, "index.js.map"), '{"sources":["secret"]}');
}
if (fixture.display !== undefined) { if (fixture.display !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display); await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
@@ -209,6 +242,7 @@ async function install(
displayHostAssets: createDisplayHostAssets( displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="display-root"></div>', '<!doctype html><div id="display-root"></div>',
"export {};\n", "export {};\n",
"export {};\n",
), ),
} }
: {}), : {}),
@@ -223,6 +257,8 @@ async function install(
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
}); });
@@ -862,6 +898,12 @@ describe("module UI artifact URL generation", () => {
}); });
}); });
interface HostContainer {
readonly children: { readonly length: number };
querySelector(selectors: string): HostContainer | null;
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
}
describe("module UI artifact publication — the generic loader consumes what is published", () => { describe("module UI artifact publication — the generic loader consumes what is published", () => {
/** /**
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and * Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
@@ -880,21 +922,36 @@ describe("module UI artifact publication — the generic loader consumes what is
} }
/** /**
* The loader forwards the container untouched to the module's own `mount` and never inspects it, * A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
* so a plain sink is enough and no DOM implementation is required. The container's type is read * root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles * rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
* against Node libs only — naming a DOM type here would be the same browser/server confusion this * DOM type here would be the same browser/server confusion this route exists to keep apart.
* route exists to keep apart.
*/ */
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"]; type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
function containerSink(): { textContent: string } { /**
return { textContent: "" }; * `happy-dom` used as a library rather than as this suite's environment: the loader now builds a
* real isolated root inside the container, and installing a DOM environment here would replace
* the module runner's resolution for a server-side suite that dynamically imports real module
* entrypoints.
*/
function containerSink(): HostContainer {
return new Window().document.createElement("div") as unknown as HostContainer;
}
/** What the mounted artifact rendered, read through its open isolated root. */
function mountedText(container: HostContainer): string {
const shadowHost = container.querySelector("[data-ekklesia-module-ui]");
return (
shadowHost?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""
);
} }
function hostContext() { function hostContext() {
return { return {
api: { request: async () => new Response(null, { status: 200 }) }, api: { request: async () => new Response(null, { status: 200 }) },
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId: "announcements", moduleId: "announcements",
permissions: { has: () => false }, permissions: { has: () => false },
principal: { kind: "anonymous" as const }, principal: { kind: "anonymous" as const },
@@ -915,9 +972,9 @@ describe("module UI artifact publication — the generic loader consumes what is
source: { moduleId: "announcements", url, version: "1.4.2" }, source: { moduleId: "announcements", url, version: "1.4.2" },
}); });
expect(container.textContent).toBe("announcements:mounted"); expect(mountedText(container)).toBe("announcements:mounted");
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(container.children).toHaveLength(0);
}); });
it("still rejects the published artifact when the host expects a different identity", async () => { it("still rejects the published artifact when the host expects a different identity", async () => {
@@ -935,3 +992,109 @@ describe("module UI artifact publication — the generic loader consumes what is
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" }); ).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
}); });
}); });
/**
* Contribution artifact publication (ADR 0041), on exactly the same terms as every other declared
* artifact: three lookup keys, one exact registry match, one already-validated canonical path.
*/
describe("module contribution artifact publication", () => {
const signage = {
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
id: "signage",
};
const contributionSource = "export default { contributed: true };\n";
const announcements = (capabilityRange = "^1.0.0") => ({
contributions: [
{
capability: "signage.slide-renderer",
capabilityRange,
localName: "signage-slide",
source: contributionSource,
},
],
id: "announcements",
});
const contributionUrl = moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "0.1.0" },
"announcements.signage-slide",
);
it("serves the declared contribution artifact byte for byte", async () => {
const { app } = await install([signage, announcements()]);
const response = await app.request(contributionUrl);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(await response.text()).toBe(contributionSource);
});
it("builds a URL that cannot select a neighbouring file", () => {
expect(contributionUrl).toBe(
"/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
);
// The contribution id is one segment's worth of text, always — a value that needed encoding
// simply fails to resolve.
expect(
moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "0.1.0" },
"../../../etc/passwd",
),
).toBe("/api/v1/module-ui/announcements/0.1.0/contributions/..%2F..%2F..%2Fetc%2Fpasswd");
});
it.each([
["an unknown contribution id", "announcements.nope"],
["another module's contribution id", "signage.slide-renderer"],
["a path traversal", "../../../etc/passwd"],
["the artifact's source map", "announcements.signage-slide.map"],
])("refuses %s with the same indistinguishable 404", async (_label, contributionId) => {
const { app } = await install([signage, announcements()]);
const response = await app.request(
moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "0.1.0" },
contributionId,
),
);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
});
it("refuses a version the platform does not have installed", async () => {
const { app } = await install([signage, announcements()]);
const response = await app.request(
moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "9.9.9" },
"announcements.signage-slide",
),
);
expect(response.status).toBe(404);
});
it("publishes nothing when no installed module provides the target capability", async () => {
const { app } = await install([announcements()]);
expect((await app.request(contributionUrl)).status).toBe(404);
});
it("publishes nothing when the provider is outside the declared compatible range", async () => {
const { app } = await install([signage, announcements("^2.0.0")]);
expect((await app.request(contributionUrl)).status).toBe(404);
});
it("keeps the sibling source map and the module's own directory unreachable", async () => {
const { app } = await install([signage, announcements()]);
for (const suffix of [".map", "/index.js.map", "/../ui/index.js"]) {
const response = await app.request(`${contributionUrl}${suffix}`);
expect(response.status).toBe(404);
}
});
});
@@ -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,
};
}
@@ -0,0 +1,20 @@
-- Email verification becomes a precondition for holding a session (ADR 0038). Any platform owner
-- that already exists was created by the trusted operator-run bootstrap command before that command
-- marked addresses verified, so without this they would be locked out of their own installation by
-- an upgrade.
--
-- Deliberately narrow. It marks exactly the identities the authorization tables already record as
-- currently-granted platform owners: an authority the deployment established through a command that
-- required database credentials, which is the same authority the bootstrap verification exception
-- rests on. Every other account is left untouched and must verify by email like any other.
--
-- Data-only and idempotent. It adds no column, drops nothing, and re-running it changes no row that
-- is not already false.
UPDATE "ekklesia_core_user"
SET "email_verified" = true
WHERE "email_verified" = false
AND "id" IN (
SELECT "user_id"
FROM "ekklesia_core_platform_owner"
WHERE "revoked_at" IS NULL
);
File diff suppressed because it is too large Load Diff
+6
View File
@@ -43,6 +43,12 @@
"destructive": { "destructive": {
"mode": "none" "mode": "none"
} }
},
{
"tag": "0007_verified_bootstrap_owners",
"destructive": {
"mode": "none"
}
} }
] ]
} }
+7
View File
@@ -50,6 +50,13 @@
"when": 1787543773057, "when": 1787543773057,
"tag": "0006_device_identity", "tag": "0006_device_identity",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1787543773058,
"tag": "0007_verified_bootstrap_owners",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/core", "name": "@ekklesia/core",
"version": "0.1.0", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"files": [ "files": [
+45
View File
@@ -14,6 +14,7 @@ export type InitialPlatformOwnerErrorCode =
| "INITIAL_OWNER_EMAIL_INVALID" | "INITIAL_OWNER_EMAIL_INVALID"
| "INITIAL_OWNER_IDENTITY_NOT_CREATED" | "INITIAL_OWNER_IDENTITY_NOT_CREATED"
| "INITIAL_OWNER_IDENTITY_UNUSABLE" | "INITIAL_OWNER_IDENTITY_UNUSABLE"
| "INITIAL_OWNER_IDENTITY_NOT_VERIFIED"
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED" | "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
| "INITIAL_OWNER_PASSWORD_TOO_LONG" | "INITIAL_OWNER_PASSWORD_TOO_LONG"
| "INITIAL_OWNER_PASSWORD_TOO_SHORT" | "INITIAL_OWNER_PASSWORD_TOO_SHORT"
@@ -28,6 +29,8 @@ const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, strin
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.", "The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
INITIAL_OWNER_IDENTITY_UNUSABLE: INITIAL_OWNER_IDENTITY_UNUSABLE:
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.", "An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
INITIAL_OWNER_IDENTITY_NOT_VERIFIED:
"The identity exists but could not be marked as a verified address, so it could not sign in. Nothing was granted; the bootstrap is still available.",
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED: INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.", "The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.", INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
@@ -131,6 +134,25 @@ async function findIdentityByEmail(
return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id }; return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id };
} }
/**
* Marks one identity's address verified.
*
* A direct column update rather than an identity-system call, because the pinned release exposes no
* supported way to mark an address verified other than consuming a token it emailed — which is the
* one thing a pre-mail installation cannot do. The column is Better Auth's own `emailVerified`, the
* exact value its `/verify-email` endpoint writes, so the resulting row is indistinguishable from
* one that verified normally; nothing here invents a parallel notion of verification.
*/
async function markIdentityEmailVerified(
db: IdentityDrizzleDatabase,
userId: string,
): Promise<void> {
await db
.update(ekklesiaCoreUser)
.set({ emailVerified: true })
.where(eq(ekklesiaCoreUser.id, userId));
}
function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError { function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
if (error instanceof BootstrapIdentityError) { if (error instanceof BootstrapIdentityError) {
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") { if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
@@ -252,6 +274,29 @@ export async function createInitialPlatformOwner(
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED"); throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
} }
/**
* The trusted-bootstrap verification exception (ADR 0038).
*
* Every other human account must verify its address by email before it may hold a session.
* This one cannot: it is created on a freshly provisioned server by an operator who already
* holds the database credentials, at a moment when SMTP may not be configured, may not be
* reachable, or may not yet have a DNS record to send from. Requiring an email round trip here
* would mean a working installation whose administrator can never get in — the platform's
* outbound mail would have to be configured and correct before anyone could sign in to
* configure anything.
*
* The exception is narrow and its authority is the same authority that already makes this
* command safe: possession of the database, not possession of the mailbox. It marks exactly one
* address, exactly once per installation, and it is deliberately performed **before** ownership
* is granted — if it fails, the sentinel and the grant roll back together and re-running the
* command adopts the identity and finishes, rather than leaving an owner who cannot sign in.
*/
try {
await markIdentityEmailVerified(dependencies.identityDb, parsedUserId.data);
} catch {
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_VERIFIED");
}
try { try {
const grant = const grant =
dependencies.grantOwnership ?? dependencies.grantOwnership ??
+22
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { PermissionRegistryError } from "../authorization/index.js"; import { PermissionRegistryError } from "../authorization/index.js";
import { import {
composePlatformPermissionRegistry, composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModuleSchemas, discoverModuleSchemas,
ModuleDiscoveryError, ModuleDiscoveryError,
ModulePermissionCompositionError, ModulePermissionCompositionError,
@@ -45,11 +46,32 @@ async function main(): Promise<void> {
return; return;
} }
// Built from the same registry the API publishes from, so this command can never disagree with
// what a running platform would actually serve. A contribution whose target capability nobody
// provides — or whose provider moved outside its declared range — is otherwise silent: the
// manifest is valid, the artifact exists, and nothing renders (ADR 0041).
const publication = createModuleUiPublicationRegistry(modules);
const compatibleContributions = new Set(
publication
.listContributions()
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
);
console.log( console.log(
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`, `Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
); );
for (const discoveredModule of modules) { for (const discoveredModule of modules) {
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`); console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
for (const contribution of discoveredModule.contributions) {
const resolved = compatibleContributions.has(
`${discoveredModule.manifest.id}\u0000${contribution.id}`,
);
console.log(
` contributes ${contribution.id}@${contribution.version} to ${contribution.capability} ${contribution.capabilityRange} — ${
resolved ? "compatible provider installed" : "no compatible provider installed"
}`,
);
}
} }
} }
+99 -5
View File
@@ -57,6 +57,7 @@ import {
createModuleConfigurationStore, createModuleConfigurationStore,
createModuleLifecycleDatabase, createModuleLifecycleDatabase,
createModuleLifecycleStore, createModuleLifecycleStore,
createModuleUiPublicationRegistry,
type DiscoveredModule, type DiscoveredModule,
discoverModuleSchemas, discoverModuleSchemas,
discoverModules, discoverModules,
@@ -116,6 +117,7 @@ export interface DoctorReport {
export interface DoctorIdentityConfigurationOptions { export interface DoctorIdentityConfigurationOptions {
readonly baseUrl?: string | undefined; readonly baseUrl?: string | undefined;
readonly dashboardOrigin?: string | undefined;
readonly environment?: CreateIdentityConfigOptions["environment"]; readonly environment?: CreateIdentityConfigOptions["environment"];
readonly publicSignupEnabled?: string | undefined; readonly publicSignupEnabled?: string | undefined;
readonly secret?: string | undefined; readonly secret?: string | undefined;
@@ -315,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) }); return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
} }
/**
* One line per installed module, plus — for a module that declares browser contributions (ADR 0041)
* — how many of them have a compatible provider among the installed set.
*
* Worth reporting because the failure it names is otherwise silent: a contribution whose target
* capability nobody provides, or whose provider moved outside its declared range, is simply not
* published, and an operator sees a screen that renders nothing with no error anywhere. The counts
* are derived from the same publication registry the API serves from, so this can never disagree
* with what the running platform would actually publish.
*
* Static only, like every other Doctor check: it reports what the *manifests* support and never
* claims a module is running. Enablement and dependency blocking are the lifecycle check's job.
*/
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] { function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
const publication = createModuleUiPublicationRegistry(modules);
const compatible = new Set(
publication
.listContributions()
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
);
return modules.map((module) => { return modules.map((module) => {
const id = module.manifest.id; const id = module.manifest.id;
const version = module.manifest.version; const version = module.manifest.version;
@@ -325,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
) { ) {
throw new TypeError("Unsafe module diagnostic value."); throw new TypeError("Unsafe module diagnostic value.");
} }
const declared = module.contributions.length;
if (declared === 0) {
return `${id} ${version}`; return `${id} ${version}`;
}
const resolved = module.contributions.filter((contribution) =>
compatible.has(`${id}\u0000${contribution.id}`),
).length;
return `${id} ${version} · ${declared} browser contribution${
declared === 1 ? "" : "s"
} (${resolved} with a compatible provider)`;
}); });
} }
@@ -727,6 +757,35 @@ function identitySchemaSummary(): string {
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`; 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 * 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 * connection, and never prints the configured secret, base URL, or trusted origins. Missing
@@ -739,6 +798,7 @@ function identityConfigurationCheck(
const hasAnyValue = const hasAnyValue =
input?.secret !== undefined || input?.secret !== undefined ||
input?.baseUrl !== undefined || input?.baseUrl !== undefined ||
(input?.dashboardOrigin ?? "") !== "" ||
input?.publicSignupEnabled !== undefined || input?.publicSignupEnabled !== undefined ||
(input?.trustedOrigins?.length ?? 0) > 0; (input?.trustedOrigins?.length ?? 0) > 0;
@@ -753,17 +813,43 @@ function identityConfigurationCheck(
const config = createIdentityConfig( const config = createIdentityConfig(
{ {
baseUrl: input?.baseUrl, 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, publicSignupEnabled: input?.publicSignupEnabled,
secret: input?.secret, secret: input?.secret,
trustedOrigins: input?.trustedOrigins, trustedOrigins: input?.trustedOrigins,
}, },
{ environment: input?.environment ?? "production" }, { 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", [ return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
schemaDetail, schemaDetail,
config.publicSignupEnabled signupDetail,
? "Public signup is explicitly ENABLED." "Topology: same-origin (the Dashboard is served from the API's origin).",
: "Public signup is disabled (safe default).", ]);
}
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,
signupDetail,
topologyDetail,
]); ]);
} catch (error) { } catch (error) {
const code = const code =
@@ -819,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 { function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
try { try {
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER); const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
if (config.mode === "direct") { if (config.mode === "direct") {
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [ 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.", "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", [ 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")}.`, `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.", "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) { } catch (error) {
const code = const code =
@@ -840,7 +934,7 @@ function clientAddressCheck(input: ClientAddressConfigInput | undefined): Doctor
"identity.client-address", "identity.client-address",
"failure", "failure",
"Client-address configuration invalid", "Client-address configuration invalid",
[`[${code}] Configured client-address values failed validation.`], [`[${code}] Configured client-address values failed validation.`, CLIENT_ADDRESS_SCOPE],
); );
} }
} }
@@ -1193,7 +1287,7 @@ export async function diagnoseEkklesia(
"skipped", "skipped",
"Outbound email not configured", "Outbound email not configured",
[ [
"No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.", "No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.",
], ],
); );
} else { } else {
+8
View File
@@ -26,6 +26,9 @@ export type HostEnvironmentName = "development" | "production";
*/ */
export interface HostIdentityEnvironmentInput { export interface HostIdentityEnvironmentInput {
readonly baseUrl: string | undefined; readonly baseUrl: string | undefined;
/** Where an emailed verification or reset link sends a browser. Unset means the Dashboard is
* served from `baseUrl`, which is the ordinary same-origin production shape (ADR 0038). */
readonly dashboardOrigin: string | undefined;
readonly publicSignupEnabled: string | undefined; readonly publicSignupEnabled: string | undefined;
readonly secret: string | undefined; readonly secret: string | undefined;
readonly trustedOrigins: readonly string[] | undefined; readonly trustedOrigins: readonly string[] | undefined;
@@ -118,6 +121,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
environment: environment.NODE_ENV === "development" ? "development" : "production", environment: environment.NODE_ENV === "development" ? "development" : "production",
identity: { identity: {
baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL, baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL,
dashboardOrigin: environment.EKKLESIA_DASHBOARD_ORIGIN,
publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED, publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
secret: environment.EKKLESIA_IDENTITY_SECRET, secret: environment.EKKLESIA_IDENTITY_SECRET,
trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS), trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS),
@@ -129,6 +133,10 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
mail: { mail: {
fromAddress: environment.EKKLESIA_SMTP_FROM_ADDRESS, fromAddress: environment.EKKLESIA_SMTP_FROM_ADDRESS,
fromName: environment.EKKLESIA_SMTP_FROM_NAME, fromName: environment.EKKLESIA_SMTP_FROM_NAME,
// The name this client announces in `EHLO`. Optional, and unset is the ordinary case: it
// matters only where the machine's own hostname is not a fully qualified name the relay will
// accept.
heloName: environment.EKKLESIA_SMTP_HELO_NAME,
host: environment.EKKLESIA_SMTP_HOST, host: environment.EKKLESIA_SMTP_HOST,
password: environment.EKKLESIA_SMTP_PASSWORD, password: environment.EKKLESIA_SMTP_PASSWORD,
port: environment.EKKLESIA_SMTP_PORT, port: environment.EKKLESIA_SMTP_PORT,
+80 -1
View File
@@ -5,6 +5,10 @@ export type IdentityConfigErrorCode =
| "BASE_URL_INVALID" | "BASE_URL_INVALID"
| "BASE_URL_LOOPBACK_IN_PRODUCTION" | "BASE_URL_LOOPBACK_IN_PRODUCTION"
| "BASE_URL_MISSING" | "BASE_URL_MISSING"
| "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION"
| "DASHBOARD_ORIGIN_INVALID"
| "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION"
| "DASHBOARD_ORIGIN_UNTRUSTED"
| "PUBLIC_SIGNUP_ENABLED_INVALID" | "PUBLIC_SIGNUP_ENABLED_INVALID"
| "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" | "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"
| "SECRET_LOW_ENTROPY" | "SECRET_LOW_ENTROPY"
@@ -127,6 +131,13 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
BASE_URL_LOOPBACK_IN_PRODUCTION: BASE_URL_LOOPBACK_IN_PRODUCTION:
"The identity base URL must not resolve to a loopback host in production.", "The identity base URL must not resolve to a loopback host in production.",
BASE_URL_MISSING: "The identity base URL is required.", BASE_URL_MISSING: "The identity base URL is required.",
DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION: "The Dashboard origin must use https in production.",
DASHBOARD_ORIGIN_INVALID:
"The Dashboard origin must be an absolute http or https origin with no path, query, or fragment.",
DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION:
"The Dashboard origin must not resolve to a loopback host in production.",
DASHBOARD_ORIGIN_UNTRUSTED:
"The Dashboard origin must also be listed as a trusted origin, or emailed links will be refused.",
PUBLIC_SIGNUP_ENABLED_INVALID: PUBLIC_SIGNUP_ENABLED_INVALID:
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".', 'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION: PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
@@ -163,6 +174,21 @@ export class IdentityConfigError extends Error {
export interface IdentityConfig { export interface IdentityConfig {
readonly baseUrl: string; readonly baseUrl: string;
/**
* The browser origin an emailed verification or password-reset link sends a person to.
*
* Defaults to `baseUrl`, which is the ordinary production shape: the Dashboard bundle and the API
* are served from one origin. It exists as its own value because they are not the same *concept* —
* `baseUrl` is where the `/api/auth` transport lives, this is where a human's browser lands — and
* a deployment may separate them (an API at `https://api.example.org`, the Dashboard at
* `https://dash.example.org`). It is deliberately **not** the public church website origin: this
* is the authenticated Dashboard host and nothing else.
*
* Validated to be one of `trustedOrigins` (or `baseUrl`, which Better Auth trusts implicitly), so
* a misconfiguration fails at startup rather than producing emailed links every recipient is
* refused at.
*/
readonly dashboardOrigin: string;
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) — /** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
* public signup never becomes reachable merely because this input was omitted, misspelled, or * public signup never becomes reachable merely because this input was omitted, misspelled, or
* loosely parsed. Development and test composition may enable it explicitly; production * loosely parsed. Development and test composition may enable it explicitly; production
@@ -176,6 +202,8 @@ export interface IdentityConfig {
export interface IdentityConfigInput { export interface IdentityConfigInput {
readonly baseUrl?: unknown; readonly baseUrl?: unknown;
/** Optional. Absent or empty means the Dashboard is served from `baseUrl`. */
readonly dashboardOrigin?: unknown;
readonly publicSignupEnabled?: unknown; readonly publicSignupEnabled?: unknown;
readonly secret?: unknown; readonly secret?: unknown;
readonly trustedOrigins?: unknown; readonly trustedOrigins?: unknown;
@@ -261,6 +289,45 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
return url.origin; return url.origin;
} }
/**
* Resolves the origin an emailed link points a browser at.
*
* Unset falls back to the identity base URL — the same-origin production shape — rather than
* failing, because a deployment that has not separated its Dashboard from its API has nothing to
* configure. When it *is* set it is held to exactly the base URL's rules and then to one more: it
* must be an origin Better Auth already trusts, because Better Auth validates the emailed callback
* against `trustedOrigins` before redirecting. Checking it here converts a broken link every
* recipient would hit into a startup failure the operator sees once.
*/
function validateDashboardOrigin(
dashboardOrigin: unknown,
baseUrl: string,
trustedOrigins: readonly string[],
environment: IdentityEnvironment,
): string {
if (dashboardOrigin === undefined || dashboardOrigin === null || dashboardOrigin === "") {
return baseUrl;
}
const url = parseCanonicalHttpOrigin(dashboardOrigin);
if (!url) {
throw new IdentityConfigError("DASHBOARD_ORIGIN_INVALID");
}
if (environment === "production") {
if (url.protocol !== "https:") {
throw new IdentityConfigError("DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION");
}
if (isLoopbackOrUnspecifiedHostname(url.hostname)) {
throw new IdentityConfigError("DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION");
}
}
// `baseUrl` is accepted without appearing in `trustedOrigins` because Better Auth itself adds the
// base URL's origin to its trusted set (`getTrustedOrigins`, pinned release).
if (url.origin !== baseUrl && !trustedOrigins.includes(url.origin)) {
throw new IdentityConfigError("DASHBOARD_ORIGIN_UNTRUSTED");
}
return url.origin;
}
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string { function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
if (typeof origin !== "string" || origin.length === 0) { if (typeof origin !== "string" || origin.length === 0) {
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID"); throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
@@ -355,7 +422,19 @@ export function createIdentityConfig(
const secret = validateSecret(input.secret); const secret = validateSecret(input.secret);
const baseUrl = validateBaseUrl(input.baseUrl, environment); const baseUrl = validateBaseUrl(input.baseUrl, environment);
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment); const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
const dashboardOrigin = validateDashboardOrigin(
input.dashboardOrigin,
baseUrl,
trustedOrigins,
environment,
);
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment); const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins }); return Object.freeze({
baseUrl,
dashboardOrigin,
publicSignupEnabled,
secret,
trustedOrigins,
});
} }
+1
View File
@@ -2,4 +2,5 @@ export * from "./bootstrap-identity.js";
export * from "./client-address.js"; export * from "./client-address.js";
export * from "./config.js"; export * from "./config.js";
export * from "./database.js"; export * from "./database.js";
export * from "./notifications.js";
export * from "./runtime.js"; export * from "./runtime.js";
+291
View File
@@ -0,0 +1,291 @@
import { MailError, type MailProvider, validateMailMessage } from "../mail/index.js";
/**
* The identity feature's outbound messages, and the seam between Better Auth's callbacks and the
* host's mail transport (ADR 0038).
*
* ## What this owns and what it deliberately does not
*
* It owns the recipient, the subject, and the two bodies. It does **not** know the SMTP host, the
* port, the TLS mode, a credential, or the sender: those are host configuration held by the mail
* provider, and there is no field on the message contract through which a caller could choose any
* of them (ADR 0037). Swapping SMTP for a provider HTTP API would not change a line of this file.
*
* It also does not own tokens. Better Auth mints, persists (for reset), expires, and consumes them;
* this receives one already-minted token and puts it in a link. There is no Ekklesia token table,
* no Ekklesia token hashing, and no Ekklesia expiry clock — a second implementation of any of those
* would be a second thing to get wrong.
*
* ## Why the link is rebuilt rather than used as given
*
* Better Auth hands these callbacks a prebuilt `url`, and that URL embeds whatever `callbackURL` or
* `redirectTo` the *requesting client* supplied. Better Auth does validate those against its trusted
* origins, and that validation is sound — but an emailed link is the one artifact a person is most
* likely to click without looking, so its destination is derived here from validated configuration
* alone and from nothing a request could influence. The token is the only value taken from Better
* Auth. No request parameter can steer where a recipient lands, independently of whether the origin
* check behaves as expected.
*/
/** What could not be delivered, for an operator — never who it was for, and never the link. */
export type IdentityNotificationKind = "password-reset" | "email-verification";
export interface IdentityNotificationFailure {
readonly kind: IdentityNotificationKind;
/** A sanitized `MailError` code, or `MAIL_SEND_FAILED` for anything unrecognized. */
readonly reason: string;
}
/**
* Where a delivery failure is reported.
*
* It exists because these callbacks must **never throw**. Better Auth's own
* `/send-verification-email` rethrows whatever the callback threw, and a thrown error there turns
* "this address has an unverified account" into a distinguishable response — an account-enumeration
* oracle that appears the moment SMTP breaks. So failure is swallowed at this boundary and surfaced
* out of band instead, where no HTTP response shape depends on it.
*
* The default implementation writes one fixed line per failure. It carries the flow and a sanitized
* code and nothing else: no address, no token, no URL, no provider text.
*/
export type IdentityNotificationReporter = (failure: IdentityNotificationFailure) => void;
export interface IdentityNotificationRecipient {
readonly email: string;
readonly name?: string;
}
export interface CreateIdentityNotificationsOptions {
/**
* The host mail provider, or `undefined` when this installation has none.
*
* `undefined` is a complete deployment (ADR 0037): the platform still runs, the initial owner
* still signs in, and only verification and recovery are unavailable — reported as
* `MAIL_NOT_CONFIGURED` through the reporter, never as a different HTTP response.
*/
readonly mail: MailProvider | undefined;
/** Where the `/api/auth` transport lives — the origin Better Auth's own endpoints are on. */
readonly authBaseUrl: string;
/** Where a human's browser lands. Validated to be a trusted origin (`./config.js`). */
readonly dashboardOrigin: string;
/** Defaults to a single fixed sanitized console line. */
readonly report?: IdentityNotificationReporter;
}
export interface IdentityNotifications {
/** Shaped for Better Auth's `emailVerification.sendVerificationEmail`. Never throws. */
readonly sendVerificationEmail: (data: {
readonly user: IdentityNotificationRecipient;
readonly token: string;
}) => Promise<void>;
/** Shaped for Better Auth's `emailAndPassword.sendResetPassword`. Never throws. */
readonly sendResetPassword: (data: {
readonly user: IdentityNotificationRecipient;
readonly token: string;
}) => Promise<void>;
}
/**
* The two query values the Dashboard bundle reads to decide which host-owned auth screen to show.
*
* They are query parameters on the Dashboard's root path rather than distinct paths deliberately:
* the shell is a static single-page bundle, and a deep path would require every deployment's static
* host to rewrite unknown paths to `index.html` before an emailed link worked at all. A query
* parameter works on any static host, including the repository's own development server.
*/
export const DASHBOARD_AUTH_LANDING_PARAMETER = "ekklesia_auth";
export const DASHBOARD_VERIFY_EMAIL_LANDING = "verify-email";
export const DASHBOARD_RESET_PASSWORD_LANDING = "reset-password";
function landingUrl(dashboardOrigin: string, landing: string): string {
const url = new URL(dashboardOrigin);
url.searchParams.set(DASHBOARD_AUTH_LANDING_PARAMETER, landing);
return url.href;
}
/**
* Better Auth's own verification URL, rebuilt from configuration.
*
* The shape is the pinned release's protocol exactly — `GET {authBase}/api/auth/verify-email?
* token=...&callbackURL=...` — so Better Auth remains the only thing that inspects, expires, or acts
* on the token. Only the destination is ours.
*/
function verificationUrl(options: CreateIdentityNotificationsOptions, token: string): string {
const callback = landingUrl(options.dashboardOrigin, DASHBOARD_VERIFY_EMAIL_LANDING);
return `${options.authBaseUrl}/api/auth/verify-email?token=${encodeURIComponent(
token,
)}&callbackURL=${encodeURIComponent(callback)}`;
}
/** Better Auth's reset callback, rebuilt the same way. It validates the token, then redirects the
* browser to the Dashboard landing with the token attached. */
function resetUrl(options: CreateIdentityNotificationsOptions, token: string): string {
const callback = landingUrl(options.dashboardOrigin, DASHBOARD_RESET_PASSWORD_LANDING);
return `${options.authBaseUrl}/api/auth/reset-password/${encodeURIComponent(
token,
)}?callbackURL=${encodeURIComponent(callback)}`;
}
/** Escapes the four characters that could otherwise close an element or attribute in the HTML body.
* Only the link and the greeting name are interpolated, and the name comes from the user record. */
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
/**
* The same rule `validateMailMessage` applies, expressed the same way it expresses it: a code-point
* scan rather than a regular expression, because a regex over a control-character range is itself
* something a linter (rightly) objects to.
*/
function hasControlCharacter(value: string): boolean {
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint === undefined || codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
return true;
}
}
return false;
}
/**
* A greeting that is safe to place in a message body.
*
* A stored display name is not trusted to be free of control characters — the message validator
* would reject the whole message for one stray carriage return, which would turn a cosmetic problem
* into an undeliverable password reset. So an unusable name simply falls back to no greeting.
*/
function greeting(name: string | undefined): string {
if (typeof name !== "string") {
return "Hello,";
}
const trimmed = name.trim();
if (trimmed.length === 0 || trimmed.length > 64 || hasControlCharacter(trimmed)) {
return "Hello,";
}
return `Hello ${trimmed},`;
}
const EXPIRY_SENTENCE = "This link expires in one hour.";
const UNEXPECTED_SENTENCE =
"If you did not expect this message, you can ignore it and nothing will change.";
interface MessageBodies {
readonly subject: string;
readonly text: string;
readonly html: string;
}
function verificationBodies(name: string | undefined, url: string): MessageBodies {
const lead = greeting(name);
return {
subject: "Verify your Ekklesia email address",
html: [
`<p>${escapeHtml(lead)}</p>`,
"<p>Confirm this email address so it can be used to sign in to Ekklesia.</p>",
`<p><a href="${escapeHtml(url)}">Verify this email address</a></p>`,
`<p>${EXPIRY_SENTENCE} ${escapeHtml(UNEXPECTED_SENTENCE)}</p>`,
].join("\n"),
text: [
lead,
"",
"Confirm this email address so it can be used to sign in to Ekklesia:",
"",
url,
"",
EXPIRY_SENTENCE,
UNEXPECTED_SENTENCE,
].join("\n"),
};
}
function resetBodies(name: string | undefined, url: string): MessageBodies {
const lead = greeting(name);
const closing = `${UNEXPECTED_SENTENCE} Your current password stays in place until a new one is chosen.`;
return {
subject: "Reset your Ekklesia password",
html: [
`<p>${escapeHtml(lead)}</p>`,
"<p>Someone asked to reset the password for this Ekklesia account.</p>",
`<p><a href="${escapeHtml(url)}">Choose a new password</a></p>`,
`<p>${EXPIRY_SENTENCE} ${escapeHtml(closing)}</p>`,
].join("\n"),
text: [
lead,
"",
"Someone asked to reset the password for this Ekklesia account. Choose a new one here:",
"",
url,
"",
EXPIRY_SENTENCE,
closing,
].join("\n"),
};
}
function defaultReporter(failure: IdentityNotificationFailure): void {
// One fixed line. The flow and a sanitized code are exactly what an operator needs in order to go
// look at `pnpm doctor`; the address, the token, and the link are exactly what must never be here.
console.error(`[identity] ${failure.kind} email was not delivered: ${failure.reason}`);
}
function failureReason(error: unknown): string {
return error instanceof MailError ? error.code : "MAIL_SEND_FAILED";
}
/**
* Builds the two Better Auth callbacks.
*
* Neither rejects. A delivery failure is reported through `report` and the callback resolves, so the
* HTTP response an unauthenticated caller sees is identical whether the address exists, whether it
* was already verified, and whether the relay was reachable.
*/
export function createIdentityNotifications(
options: CreateIdentityNotificationsOptions,
): IdentityNotifications {
const report = options.report ?? defaultReporter;
const deliver = async (
kind: IdentityNotificationKind,
recipient: IdentityNotificationRecipient,
bodies: MessageBodies,
): Promise<void> => {
if (!options.mail) {
report({ kind, reason: "MAIL_NOT_CONFIGURED" });
return;
}
try {
await options.mail.send(
validateMailMessage({
html: bodies.html,
subject: bodies.subject,
text: bodies.text,
to: recipient.email,
}),
);
} catch (error) {
report({ kind, reason: failureReason(error) });
}
};
const notifications: IdentityNotifications = {
sendResetPassword: async (data) => {
await deliver(
"password-reset",
data.user,
resetBodies(data.user.name, resetUrl(options, data.token)),
);
},
sendVerificationEmail: async (data) => {
await deliver(
"email-verification",
data.user,
verificationBodies(data.user.name, verificationUrl(options, data.token)),
);
},
};
return Object.freeze(notifications);
}
+119 -5
View File
@@ -9,6 +9,7 @@ import { getCookies } from "better-auth/cookies";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import type { IdentityConfig } from "./config.js"; import type { IdentityConfig } from "./config.js";
import type { IdentityDrizzleDatabase } from "./database.js"; import type { IdentityDrizzleDatabase } from "./database.js";
import type { IdentityNotifications } from "./notifications.js";
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js"; import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
export type IdentityRuntimeErrorCode = export type IdentityRuntimeErrorCode =
@@ -46,6 +47,16 @@ export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address";
*/ */
const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error."; const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error.";
/**
* One hour for both, matching the pinned release's own defaults but stated rather than inherited.
*
* Short enough that a link sitting in a forwarded or archived mailbox stops working, long enough
* that someone who reads their mail after lunch is not sent back to the start. The message bodies
* state the same hour, so the two can be checked against each other.
*/
const EMAIL_VERIFICATION_TOKEN_TTL_SECONDS = 3600;
const RESET_PASSWORD_TOKEN_TTL_SECONDS = 3600;
/** /**
* Explicitly replaces Better Auth's default logger so its own message text and arguments — which * Explicitly replaces Better Auth's default logger so its own message text and arguments — which
* may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a * may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a
@@ -82,10 +93,29 @@ export interface CreateIdentityRuntimeOptions {
readonly config: IdentityConfig; readonly config: IdentityConfig;
readonly db: IdentityDrizzleDatabase; readonly db: IdentityDrizzleDatabase;
/** /**
* A narrow, explicit override for the sign-in/sign-up rate-limit window used only by test * How verification and password-reset messages are delivered.
* composition that issues many such requests quickly. Rate limiting itself is always enabled; *
* production composition must leave this unset so Better Auth's own strict built-in default * Injected rather than constructed here, for the same reason the database is: this file composes
* (3 requests / 10 seconds on sign-in and sign-up) applies unmodified. * Better Auth and must not decide what a message says or how it travels. Omitting it is a
* complete, valid deployment — email verification and password recovery are then configured
* exactly as they otherwise are, and each attempt reports `MAIL_NOT_CONFIGURED` out of band while
* every HTTP response keeps its usual shape (ADR 0038).
*
* Note what omitting it does **not** do: it does not relax `requireEmailVerification`. An
* installation with no mail cannot verify a new account, which is why the initial platform owner
* is created already verified by a trusted operator command rather than by an email round trip.
*/
readonly notifications?: IdentityNotifications;
/**
* A narrow, explicit override for the rate-limit windows on the four paths Better Auth treats
* more strictly than its global default, used only by test composition that issues many such
* requests quickly. Rate limiting itself is always enabled; production composition must leave this
* unset so Better Auth's own built-in defaults apply unmodified — 3 requests / 10 seconds on
* sign-in and sign-up, and 3 requests / 60 seconds on password-reset requests and verification
* resends, which are the two paths an attacker could otherwise use to send mail repeatedly.
*
* That limiter is per-process and in-memory (ADR 0038): it is a real abuse control for a single
* deployment and is deliberately **not** claimed to be a distributed one.
*/ */
readonly rateLimitOverride?: IdentityRateLimitOverride; readonly rateLimitOverride?: IdentityRateLimitOverride;
} }
@@ -264,9 +294,81 @@ export function createIdentityRuntime(
enabled: true, enabled: true,
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128` // Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
// in the pinned release): an implicit default can change silently on a future Better Auth // in the pinned release): an implicit default can change silently on a future Better Auth
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. // upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. The reset endpoint
// enforces the identical pair from the same configuration, so there is deliberately no
// separate reset-time password policy.
maxPasswordLength: 128, maxPasswordLength: 128,
minPasswordLength: 8, minPasswordLength: 8,
/**
* A person may not hold a session until their address is verified (ADR 0038).
*
* In the pinned release this is enforced *after* the password is checked and *before* any
* session is created: the attempt fails with `403 EMAIL_NOT_VERIFIED` and no cookie is set.
* That is the whole reason no "unverified principal" exists anywhere in this platform — a
* correct password on an unverified account produces no session at all, so there is no
* half-authenticated state for the Dashboard to misread. `/api/v1/principal` continues to
* describe only anonymous, authenticated human, and device.
*/
requireEmailVerification: true,
/** Pinned rather than left on the release's `|| 3600` fallback, for the same reason the
* password bounds are pinned. */
resetPasswordTokenExpiresIn: RESET_PASSWORD_TOKEN_TTL_SECONDS,
/**
* Every existing session for that account is deleted when its password is reset.
*
* Password reset is the recovery path someone uses precisely when they suspect their
* credential is compromised, and leaving the attacker's session alive would make the recovery
* cosmetic. This is Better Auth's own supported option (`deleteUserSessions` in the pinned
* release), not a Core reach into the session table.
*
* It deletes *all* of that human's sessions, including the browser that performed the reset —
* so the completion state returns to the sign-in form rather than pretending to be signed in.
* Managed device sessions are a separate Ekklesia store keyed by device, hold no email and no
* password, and are entirely unaffected (ADR 0036).
*/
revokeSessionsOnPasswordReset: true,
...(options.notifications
? { sendResetPassword: options.notifications.sendResetPassword }
: {}),
},
/**
* Verification is configured identically whether or not mail is deliverable, so the policy an
* installation enforces never depends on the state of its relay.
*/
emailVerification: {
/**
* No session is minted from following an emailed link.
*
* A verification URL travels through mail infrastructure and is routinely fetched by things
* that are not the recipient — link scanners, prefetchers, corporate mail gateways. Turning
* that GET into a logged-in browser session would hand a session to whoever, or whatever,
* dereferenced the link. Verification therefore only flips the flag, and the person signs in
* afterwards through the ordinary form.
*/
autoSignInAfterVerification: false,
/** Pinned rather than left on the release's `|| 3600` fallback. */
expiresIn: EMAIL_VERIFICATION_TOKEN_TTL_SECONDS,
/**
* A sign-in attempt against an unverified account re-sends the verification message.
*
* This is the resend path that costs nothing to secure: reaching it requires the correct
* password, so it reveals nothing an attacker did not already know, and it means the common
* case — the first link expired — is handled by the thing the person was already trying to do.
* The standalone `/send-verification-email` endpoint remains available for the case where they
* would rather not retype a password, and is enumeration-safe on its own.
*/
sendOnSignIn: true,
/**
* Pinned rather than left on the release's "follow `requireEmailVerification`" fallback.
*
* It reaches nothing in production, where signup is refused outright — but stating it means a
* development or test deployment that does enable signup behaves the way this platform
* intends, rather than the way an inherited default happens to.
*/
sendOnSignUp: true,
...(options.notifications
? { sendVerificationEmail: options.notifications.sendVerificationEmail }
: {}),
}, },
logger: { logger: {
disabled: false, disabled: false,
@@ -293,6 +395,18 @@ export function createIdentityRuntime(
...(rateLimitOverride ...(rateLimitOverride
? { ? {
customRules: { customRules: {
// The four paths Better Auth applies a stricter-than-global default to. Production
// composition never sets this override, so those defaults — 3 requests / 10 seconds
// on sign-in and sign-up, 3 requests / 60 seconds on the two paths that trigger an
// email — remain in force there, unmodified.
"/request-password-reset": {
max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10,
},
"/send-verification-email": {
max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10,
},
"/sign-in/email": { "/sign-in/email": {
max: rateLimitOverride.max ?? 100, max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10, window: rateLimitOverride.window ?? 10,
+61 -1
View File
@@ -8,6 +8,7 @@ export type MailConfigErrorCode =
| "MAIL_FROM_ADDRESS_INVALID" | "MAIL_FROM_ADDRESS_INVALID"
| "MAIL_FROM_ADDRESS_MISSING" | "MAIL_FROM_ADDRESS_MISSING"
| "MAIL_FROM_NAME_INVALID" | "MAIL_FROM_NAME_INVALID"
| "MAIL_HELO_NAME_INVALID"
| "MAIL_HOST_INVALID" | "MAIL_HOST_INVALID"
| "MAIL_PASSWORD_WITHOUT_USERNAME" | "MAIL_PASSWORD_WITHOUT_USERNAME"
| "MAIL_PORT_INVALID" | "MAIL_PORT_INVALID"
@@ -19,6 +20,8 @@ const mailConfigMessages: Readonly<Record<MailConfigErrorCode, string>> = Object
MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.", MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.",
MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.", MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.",
MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`, MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`,
MAIL_HELO_NAME_INVALID:
"The SMTP HELO name must be a fully qualified domain name, with no scheme, port, brackets, or spaces.",
MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.", MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.",
MAIL_PASSWORD_WITHOUT_USERNAME: MAIL_PASSWORD_WITHOUT_USERNAME:
"An SMTP password requires a username; supply both or neither for an unauthenticated relay.", "An SMTP password requires a username; supply both or neither for an unauthenticated relay.",
@@ -70,6 +73,7 @@ export type MailSecurityMode = (typeof MAIL_SECURITY_MODES)[number];
export interface MailProviderConfigInput { export interface MailProviderConfigInput {
readonly host?: string | undefined; readonly host?: string | undefined;
readonly heloName?: string | undefined;
readonly port?: string | number | undefined; readonly port?: string | number | undefined;
readonly security?: string | undefined; readonly security?: string | undefined;
readonly username?: string | undefined; readonly username?: string | undefined;
@@ -87,6 +91,24 @@ export interface MailProviderConfigInput {
*/ */
export interface MailProviderConfig { export interface MailProviderConfig {
readonly host: string; readonly host: string;
/**
* The name this client identifies itself as in the SMTP `EHLO` command, when the deployment sets
* one.
*
* This exists because of a real failure, and the failure is generic rather than tied to any one
* provider. An SMTP client must greet the server with its own identity, and when nothing is
* configured Nodemailer derives it from the machine's hostname — falling back to the address
* literal `[127.0.0.1]` whenever that hostname is not fully qualified, which is the normal state
* of a container or a VM nobody has named. Some relays refuse that greeting outright, closing the
* connection with a `421` before any message is offered, which surfaces here as a bare
* `MAIL_PROVIDER_UNAVAILABLE` and looks indistinguishable from the relay being down.
*
* Absent means Nodemailer's own derivation is used, unchanged. It is deliberately not defaulted to
* the sender's domain: a host whose machine hostname *is* a valid FQDN may already be greeting
* correctly — some relays check it against the connecting address's reverse DNS — and silently
* replacing a working identity would trade one deployment's bug for another's.
*/
readonly heloName?: string;
readonly port: number; readonly port: number;
readonly security: MailSecurityMode; readonly security: MailSecurityMode;
/** Present only for an authenticated relay. A relay authorized by source IP — the common Google /** Present only for an authenticated relay. A relay authorized by source IP — the common Google
@@ -119,6 +141,29 @@ function validateHost(host: string): string {
return trimmed; return trimmed;
} }
/**
* A fully qualified domain name, and deliberately nothing else.
*
* Stricter than `HOST_PATTERN` on purpose, in two ways. It requires **at least one dot**, because a
* single unqualified label is precisely the input Nodemailer already refuses to use — configuring
* one would reproduce the failure this setting exists to fix. And it requires the final label to
* begin with a letter, which rejects a bare IPv4 address: `EHLO 192.0.2.1` is not valid SMTP syntax,
* and the bracketed address-literal form that *is* valid is the greeting the affected relays reject.
*
* Everything the requirement rules out falls out of the anchors and the character class rather than
* from a list of special cases: a scheme, a port, a path, whitespace, brackets, a comma, a
* carriage return or line feed, and any other prose all fail to match.
*/
const HELO_NAME_PATTERN =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu;
function validateHeloName(heloName: string): string {
if (!HELO_NAME_PATTERN.test(heloName)) {
throw new MailConfigError("MAIL_HELO_NAME_INVALID");
}
return heloName;
}
function validatePort(port: string | number | undefined, security: MailSecurityMode): number { function validatePort(port: string | number | undefined, security: MailSecurityMode): number {
if (port === undefined || (typeof port === "string" && port.trim() === "")) { if (port === undefined || (typeof port === "string" && port.trim() === "")) {
return DEFAULT_PORTS[security]; return DEFAULT_PORTS[security];
@@ -174,6 +219,16 @@ export function createMailProviderConfig(
const validatedHost = validateHost(host); const validatedHost = validateHost(host);
const port = validatePort(input?.port, security); const port = validatePort(input?.port, security);
// Validated exactly as supplied, with no trimming. Every other value here tolerates surrounding
// whitespace as a copy-paste artefact; this one must not, because a stray character in the value
// goes straight onto the wire in a protocol command. It is refused rather than repaired, the same
// rule an address follows.
const heloNameInput = input?.heloName;
const heloName =
heloNameInput === undefined || heloNameInput === ""
? undefined
: validateHeloName(heloNameInput);
const username = input?.username?.trim() === "" ? undefined : input?.username?.trim(); const username = input?.username?.trim() === "" ? undefined : input?.username?.trim();
const password = input?.password === "" ? undefined : input?.password; const password = input?.password === "" ? undefined : input?.password;
if (username !== undefined && password === undefined) { if (username !== undefined && password === undefined) {
@@ -201,6 +256,7 @@ export function createMailProviderConfig(
host: validatedHost, host: validatedHost,
port, port,
security, security,
...(heloName === undefined ? {} : { heloName }),
...(fromName === undefined ? {} : { fromName }), ...(fromName === undefined ? {} : { fromName }),
...(username === undefined || password === undefined ? {} : { password, username }), ...(username === undefined || password === undefined ? {} : { password, username }),
}); });
@@ -219,7 +275,11 @@ export function describeMailProvider(config: MailProviderConfig): string {
config.fromName === undefined config.fromName === undefined
? config.fromAddress ? config.fromAddress
: `${config.fromName} <${config.fromAddress}>`; : `${config.fromName} <${config.fromAddress}>`;
// The HELO name is host infrastructure, not a credential: it is the identity this client announces
// in the clear on every connection, so printing it costs nothing and is exactly what an operator
// needs when a relay has refused the greeting.
const helo = config.heloName === undefined ? "" : ` · HELO ${config.heloName}`;
return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${ return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${
config.username === undefined ? "unauthenticated relay" : "authenticated" config.username === undefined ? "unauthenticated relay" : "authenticated"
}`; }${helo}`;
} }
+5
View File
@@ -126,6 +126,11 @@ function transportOptions(
disableUrlAccess: true, disableUrlAccess: true,
greetingTimeout: bounds.greetingTimeoutMs, greetingTimeout: bounds.greetingTimeoutMs,
host: config.host, host: config.host,
// The identity this client announces in `EHLO`. Left to Nodemailer when the deployment
// configures none — it derives one from the machine hostname, falling back to the address
// literal `[127.0.0.1]` when that hostname is not fully qualified, which some relays refuse
// with a `421` before a message is ever offered (ADR 0037).
...(config.heloName === undefined ? {} : { name: config.heloName }),
// Deliberately not a pooled transport: `pool` is absent, which is Nodemailer's default and is // Deliberately not a pooled transport: `pool` is absent, which is Nodemailer's default and is
// what this type permits. A pool keeps sockets open and reuses them across sends, which for a // what this type permits. A pool keeps sockets open and reuses them across sends, which for a
// platform sending a handful of transactional messages buys nothing and makes the failure modes // platform sending a handful of transactional messages buys nothing and makes the failure modes
+84 -6
View File
@@ -57,6 +57,19 @@ export interface ModuleDiscoveryIssue {
readonly path?: string; readonly path?: string;
} }
/**
* One declared browser contribution, with its artifact resolved and validated exactly like every
* other compiled entrypoint (ADR 0041). Discovery never imports or evaluates it, and never learns
* what it renders.
*/
export interface ResolvedModuleContribution {
readonly id: string;
readonly version: string;
readonly capability: string;
readonly capabilityRange: string;
readonly artifactPath: string;
}
export interface ResolvedModuleEntrypoints { export interface ResolvedModuleEntrypoints {
readonly server: string; readonly server: string;
readonly schema?: string; readonly schema?: string;
@@ -92,6 +105,9 @@ export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
export interface DiscoveredModule { export interface DiscoveredModule {
readonly directory: string; readonly directory: string;
/** Declared browser contributions, in manifest order, each resolved to a canonical artifact path
* inside this module package. Empty for a module that contributes nothing. */
readonly contributions: readonly ResolvedModuleContribution[];
readonly entrypoints: ResolvedModuleEntrypoints; readonly entrypoints: ResolvedModuleEntrypoints;
readonly manifest: ReadonlyModuleManifest; readonly manifest: ReadonlyModuleManifest;
readonly manifestPath: string; readonly manifestPath: string;
@@ -110,6 +126,7 @@ interface ModulePackageData {
} }
interface ModuleCandidate { interface ModuleCandidate {
contributions: readonly ResolvedModuleContribution[];
directory: string; directory: string;
directoryName: string; directoryName: string;
entrypoints: ResolvedModuleEntrypoints; entrypoints: ResolvedModuleEntrypoints;
@@ -257,13 +274,19 @@ function validatePackageData(
return { name, version }; return { name, version };
} }
interface ResolvedModuleArtifacts {
readonly contributions: readonly ResolvedModuleContribution[];
readonly entrypoints: ResolvedModuleEntrypoints;
}
async function resolveEntrypoints( async function resolveEntrypoints(
moduleDirectory: string, moduleDirectory: string,
directoryName: string, directoryName: string,
manifest: ModuleManifest, manifest: ModuleManifest,
issues: ModuleDiscoveryIssue[], issues: ModuleDiscoveryIssue[],
): Promise<ResolvedModuleEntrypoints | undefined> { ): Promise<ResolvedModuleArtifacts | undefined> {
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {}; const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
const resolvedContributions = new Map<string, string>();
const canonicalModuleDirectory = await realpath(moduleDirectory); const canonicalModuleDirectory = await realpath(moduleDirectory);
const { display, ...ordinaryEntrypoints } = manifest.entrypoints; const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints) const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
@@ -273,6 +296,15 @@ async function resolveEntrypoints(
issuePath: `entrypoints.${entrypointName}`, issuePath: `entrypoints.${entrypointName}`,
relativeEntrypoint, relativeEntrypoint,
})); }));
// A contribution artifact is resolved through the identical loop as every other compiled
// entrypoint, deliberately: the path, symlink, containment, extension, and file-kind protections
// are one implementation, so a contribution can never quietly receive weaker checks than
// `entrypoints.ui` (ADR 0041).
const configuredContributions = manifest.contributions.map((contribution, index) => ({
entrypointName: `contribution:${contribution.id}`,
issuePath: `contributions.${index}.artifact`,
relativeEntrypoint: contribution.artifact,
}));
const configuredEntrypoints = [ const configuredEntrypoints = [
...ordinaryConfiguredEntrypoints, ...ordinaryConfiguredEntrypoints,
...(display === undefined ...(display === undefined
@@ -284,6 +316,7 @@ async function resolveEntrypoints(
relativeEntrypoint: display.artifact, relativeEntrypoint: display.artifact,
}, },
]), ]),
...configuredContributions,
]; ];
let entrypointCollision = false; let entrypointCollision = false;
@@ -361,7 +394,14 @@ async function resolveEntrypoints(
}); });
continue; continue;
} }
if (entrypointName.startsWith("contribution:")) {
resolvedContributions.set(
entrypointName.slice("contribution:".length),
canonicalEntrypoint,
);
} else {
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint; resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
}
} catch (error) { } catch (error) {
const code = errorCode(error); const code = errorCode(error);
issues.push({ issues.push({
@@ -394,11 +434,46 @@ async function resolveEntrypoints(
} }
} }
if (!resolved.server || entrypointCollision) { // A contribution artifact must be its own file. Two declarations resolving to one path — through
// a link, since the manifest already rejects two identical declared paths — would make one
// contribution silently serve another's bytes, and the loader's identity check would then reject
// whichever one lost.
const canonicalContributionPaths = new Map<string, string>();
for (const [contributionId, canonicalPath] of resolvedContributions) {
const claimedBy = canonicalContributionPaths.get(canonicalPath);
if (claimedBy !== undefined || Object.values(resolved).includes(canonicalPath)) {
entrypointCollision = true;
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: `contribution ${contributionId} must not resolve to the same file as ${claimedBy ?? "another entrypoint"}.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "contributions",
});
continue;
}
canonicalContributionPaths.set(canonicalPath, contributionId);
}
if (
!resolved.server ||
entrypointCollision ||
resolvedContributions.size !== manifest.contributions.length
) {
return undefined; return undefined;
} }
return { const contributions = manifest.contributions.map((contribution) =>
Object.freeze({
artifactPath: resolvedContributions.get(contribution.id) as string,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: contribution.id,
version: contribution.version,
}),
);
const entrypoints: ResolvedModuleEntrypoints = {
server: resolved.server, server: resolved.server,
...(resolved.schema ? { schema: resolved.schema } : {}), ...(resolved.schema ? { schema: resolved.schema } : {}),
...(resolved.migrations ? { migrations: resolved.migrations } : {}), ...(resolved.migrations ? { migrations: resolved.migrations } : {}),
@@ -413,6 +488,8 @@ async function resolveEntrypoints(
} }
: {}), : {}),
}; };
return { contributions: Object.freeze(contributions), entrypoints };
} }
async function scanModuleDirectory( async function scanModuleDirectory(
@@ -510,15 +587,16 @@ async function scanModuleDirectory(
}); });
} }
const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues); const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!entrypoints) { if (!artifacts) {
return undefined; return undefined;
} }
return { return {
contributions: artifacts.contributions,
directory: canonicalModuleDirectory, directory: canonicalModuleDirectory,
directoryName, directoryName,
entrypoints, entrypoints: artifacts.entrypoints,
manifest, manifest,
manifestPath: canonicalManifestPath, manifestPath: canonicalManifestPath,
packageName: packageData.name, packageName: packageData.name,
+2
View File
@@ -60,6 +60,7 @@ export type ModuleRuntimeIssueCode =
| "MODULE_ROLLBACK_TIMEOUT" | "MODULE_ROLLBACK_TIMEOUT"
| "MODULE_ROUTE_DEFINITION_INVALID" | "MODULE_ROUTE_DEFINITION_INVALID"
| "MODULE_ROUTE_DUPLICATE" | "MODULE_ROUTE_DUPLICATE"
| "MODULE_ROUTE_BODY_MODE_INVALID"
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" | "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
| "MODULE_ROUTE_METHOD_INVALID" | "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_OVERLAP" | "MODULE_ROUTE_OVERLAP"
@@ -467,6 +468,7 @@ class StagedModuleHttpRoutes {
shapes.add(shapeKey); shapes.add(shapeKey);
local.push( local.push(
Object.freeze({ Object.freeze({
bodyMode: parsed.data.bodyMode,
handler: parsed.data.handler, handler: parsed.data.handler,
maxBodyBytes: parsed.data.maxBodyBytes, maxBodyBytes: parsed.data.maxBodyBytes,
method: parsed.data.method, method: parsed.data.method,
+155 -1
View File
@@ -1,4 +1,8 @@
import type { PermissionDeclaration, PermissionId } from "@ekklesia/contracts"; import {
isVersionCompatible,
type PermissionDeclaration,
type PermissionId,
} from "@ekklesia/contracts";
import type { DiscoveredModule } from "./discovery.js"; import type { DiscoveredModule } from "./discovery.js";
/** /**
@@ -60,6 +64,34 @@ export interface PublishableModuleDisplay {
readonly permissions: readonly PermissionDeclaration[]; readonly permissions: readonly PermissionDeclaration[];
} }
/**
* One installed module's browser contribution that the platform is willing to publish (ADR 0041).
*
* Publishable means three things, and nothing beyond them: the contributing module survived
* discovery and is in the eligible set, some **other** eligible module provides the targeted
* capability, and that provider's declared capability version satisfies the contribution's declared
* range. A contribution whose capability nobody provides, or whose provider moved outside its range,
* is simply absent — never published-but-broken, and never mounted with a shrug.
*
* Note what is *not* here. There is no payload schema, no slot, no ordering, no rendering hint, and
* no domain vocabulary of any kind: Core carries identity, version, compatibility, and one file
* path. What a contributed renderer draws, and what shape its input takes, are the consumer module's
* business and travel between the two modules in the browser without passing through the platform.
*/
export interface PublishableModuleContribution {
/** The module that *contributes* the renderer. */
readonly moduleId: string;
readonly moduleVersion: string;
readonly contributionId: string;
/** The contribution's own version, independent of its module's. */
readonly version: string;
readonly capability: string;
/** The version the providing module declares for that capability — the value the contribution's
* declared range was actually checked against. */
readonly capabilityVersion: string;
readonly artifactPath: string;
}
/** /**
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?" * The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
* *
@@ -89,6 +121,38 @@ export interface ModuleUiPublicationRegistry {
* a display merely because a caller appends `/display` to its publication URL. * a display merely because a caller appends `/display` to its publication URL.
*/ */
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined; resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
/** Every publishable browser contribution, in discovery order then manifest order. Frozen. */
listContributions(): readonly PublishableModuleContribution[];
/**
* Every publishable contribution targeting one capability, in the same stable order.
*
* This is the lookup a *consumer* module's descriptor is built from, and the reason a consumer can
* only ever learn about renderers aimed at a capability it actually provides: the caller passes
* its own declared capability id and gets back exactly the contributions for it.
*/
contributionsForCapability(capabilityId: unknown): readonly PublishableModuleContribution[];
/**
* Every publishable contribution aimed at **any** capability the named module provides.
*
* This is the one lookup a descriptor for a consuming module is built from. Framing it per module
* rather than per capability is what keeps capability ownership inside Core: a caller hands over a
* module id and gets that module's own extension points filled in, with no way to ask about
* someone else's.
*/
contributionsForModule(moduleId: unknown): readonly PublishableModuleContribution[];
/**
* The publishable record for an exact contributing module id, installed module version, and
* contribution id, or `undefined`.
*
* All three must match. A contribution that is not installed, one whose module is at a different
* version, one whose capability has no compatible provider, and an id that was never declared are
* all indistinguishable here — each returns `undefined`.
*/
resolveContribution(
moduleId: unknown,
moduleVersion: unknown,
contributionId: unknown,
): PublishableModuleContribution | undefined;
} }
/** /**
@@ -124,6 +188,41 @@ export function createModuleUiPublicationRegistry(
): ModuleUiPublicationRegistry { ): ModuleUiPublicationRegistry {
const byModuleId = new Map<string, PublishableModuleUi>(); const byModuleId = new Map<string, PublishableModuleUi>();
const displaysByModuleId = new Map<string, PublishableModuleDisplay>(); const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
const contributions: PublishableModuleContribution[] = [];
// Every capability the eligible set actually provides, at the version its provider declares. Built
// from the same list everything else here is built from, so a contribution can never be checked
// against a provider the platform is not running.
const capabilityVersions = new Map<string, string>();
for (const module of modules) {
for (const capability of module.manifest.capabilities) {
capabilityVersions.set(capability.id, capability.version);
}
}
for (const module of modules) {
for (const contribution of module.contributions) {
const capabilityVersion = capabilityVersions.get(contribution.capability);
// Fails closed on both halves: an absent provider and an incompatible one are equally
// unpublishable, so a range mismatch can never be papered over by mounting anyway.
if (
capabilityVersion === undefined ||
!isVersionCompatible(capabilityVersion, contribution.capabilityRange)
) {
continue;
}
contributions.push(
Object.freeze({
artifactPath: contribution.artifactPath,
capability: contribution.capability,
capabilityVersion,
contributionId: contribution.id,
moduleId: module.manifest.id,
moduleVersion: module.manifest.version,
version: contribution.version,
}),
);
}
}
for (const module of modules) { for (const module of modules) {
const artifactPath = module.entrypoints.ui; const artifactPath = module.entrypoints.ui;
@@ -160,8 +259,63 @@ export function createModuleUiPublicationRegistry(
const published = Object.freeze([...byModuleId.values()]); const published = Object.freeze([...byModuleId.values()]);
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]); const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
const publishedContributions = Object.freeze([...contributions]);
const contributionsByCapability = new Map<string, PublishableModuleContribution[]>();
const contributionsByKey = new Map<string, PublishableModuleContribution>();
for (const contribution of publishedContributions) {
const forCapability = contributionsByCapability.get(contribution.capability) ?? [];
forCapability.push(contribution);
contributionsByCapability.set(contribution.capability, forCapability);
// Module id and contribution id are both namespaced and unique, so this key cannot collide.
contributionsByKey.set(
`${contribution.moduleId}\u0000${contribution.contributionId}`,
contribution,
);
}
const frozenByCapability = new Map(
[...contributionsByCapability].map(([capability, list]) => [capability, Object.freeze(list)]),
);
const noContributions: readonly PublishableModuleContribution[] = Object.freeze([]);
const byConsumingModule = new Map<string, readonly PublishableModuleContribution[]>(
modules.map((module) => [
module.manifest.id,
Object.freeze(
module.manifest.capabilities.flatMap(
(capability) => contributionsByCapability.get(capability.id) ?? [],
),
),
]),
);
return Object.freeze({ return Object.freeze({
contributionsForCapability: (
capabilityId: unknown,
): readonly PublishableModuleContribution[] =>
typeof capabilityId === "string"
? (frozenByCapability.get(capabilityId) ?? noContributions)
: noContributions,
contributionsForModule: (moduleId: unknown): readonly PublishableModuleContribution[] =>
typeof moduleId === "string"
? (byConsumingModule.get(moduleId) ?? noContributions)
: noContributions,
listContributions: (): readonly PublishableModuleContribution[] => publishedContributions,
resolveContribution: (
moduleId: unknown,
moduleVersion: unknown,
contributionId: unknown,
): PublishableModuleContribution | undefined => {
if (
typeof moduleId !== "string" ||
typeof moduleVersion !== "string" ||
typeof contributionId !== "string"
) {
return undefined;
}
const candidate = contributionsByKey.get(`${moduleId}\u0000${contributionId}`);
// Exact installed-version equality, exactly as the UI and display lookups require: a request
// naming a version the platform does not have installed resolves to nothing at all.
return candidate?.moduleVersion === moduleVersion ? candidate : undefined;
},
list: (): readonly PublishableModuleUi[] => published, list: (): readonly PublishableModuleUi[] => published,
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays, listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => { resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
+1 -1
View File
@@ -91,7 +91,7 @@ async function createModule(
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
: {}), : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: fixture.name, name: fixture.name,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
+32 -1
View File
@@ -69,6 +69,7 @@ const mailEnvironment = Object.freeze({
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule { function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
return { return {
contributions: [],
directory: `/unpublished/${id}`, directory: `/unpublished/${id}`,
entrypoints: { server: `/unpublished/${id}/server.js` }, entrypoints: { server: `/unpublished/${id}/server.js` },
manifest: { manifest: {
@@ -81,7 +82,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
}, },
entrypoints: { server: "./dist/server.js" }, entrypoints: { server: "./dist/server.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: [], permissions: [],
storage: { buckets: [] }, storage: { buckets: [] },
@@ -363,6 +364,36 @@ describe("Doctor host-configuration parity — outbound email regression", () =>
expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword); expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword);
}); });
it("probes with the SMTP client identity the runtime would use, and reports it", async () => {
const services = dependencies();
const { report } = await diagnose(
environment({ ...mailEnvironment, EKKLESIA_SMTP_HELO_NAME: "mail.example.org" }),
services,
);
const mailCheck = check(report, "outbound-email.configuration");
// Doctor builds its provider from the same validated configuration `startApi` does, so a relay
// that refuses the greeting fails here rather than only when a reset email silently never
// arrives. The name is announced in the clear on every connection, so showing it leaks nothing.
expect(services.probeMail).toHaveBeenCalledWith(
expect.objectContaining({ heloName: "mail.example.org" }),
);
expect(mailCheck.details[0]).toContain("HELO mail.example.org");
expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword);
});
it("reports a malformed SMTP client identity as a typed failure, naming no value", async () => {
const { report } = await diagnose(
environment({ ...mailEnvironment, EKKLESIA_SMTP_HELO_NAME: "smtp://internal-host:2525" }),
);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("failure");
expect(mailCheck.details[0]).toBe("The SMTP settings were rejected: MAIL_HELO_NAME_INVALID.");
expect(JSON.stringify(mailCheck)).not.toContain("internal-host");
});
it("leaves a malformed SMTP setting reported as a typed configuration failure", async () => { it("leaves a malformed SMTP setting reported as a typed configuration failure", async () => {
const { report } = await diagnose( const { report } = await diagnose(
environment({ ...mailEnvironment, EKKLESIA_SMTP_PORT: "70000" }), environment({ ...mailEnvironment, EKKLESIA_SMTP_PORT: "70000" }),
+2 -1
View File
@@ -117,6 +117,7 @@ Identity
Identity runtime configuration (signing key, base URL, trusted origins) is not set. Identity runtime configuration (signing key, base URL, trusted origins) is not set.
✓ Direct client-address mode ✓ Direct client-address mode
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted. 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
○ Object storage not configured ○ Object storage not configured
@@ -126,7 +127,7 @@ Object storage
Outbound email Outbound email
○ Outbound email not configured ○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet. No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
Result: READY WITH WARNINGS`); Result: READY WITH WARNINGS`);
}); });
+187 -1
View File
@@ -30,6 +30,7 @@ const checksumC = "c".repeat(64);
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule { function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
return { return {
contributions: [],
directory: `/unpublished/${id}`, directory: `/unpublished/${id}`,
entrypoints: { server: `/unpublished/${id}/server.js` }, entrypoints: { server: `/unpublished/${id}/server.js` },
manifest: { manifest: {
@@ -42,7 +43,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
}, },
entrypoints: { server: "./dist/server.js" }, entrypoints: { server: "./dist/server.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: [], permissions: [],
storage: { buckets: [] }, storage: { buckets: [] },
@@ -704,6 +705,88 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture 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 () => { it("fails identity configuration for an invalid secret without leaking it", async () => {
const hostileSecret = "s3cr3t-do-not-log"; const hostileSecret = "s3cr3t-do-not-log";
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
@@ -875,6 +958,21 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(report.result).toBe("ready-with-warnings"); 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 () => { it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ {
@@ -1471,3 +1569,91 @@ describe("Ekklesia Doctor — object storage", () => {
expect(check(report, "object-storage.bindings").status).toBe("skipped"); expect(check(report, "object-storage.bindings").status).toBe("skipped");
}); });
}); });
/**
* A browser contribution whose target capability nobody provides is otherwise silent: the manifest
* is valid, the artifact exists, and the screen renders nothing with no error anywhere (ADR 0041).
* Doctor names it.
*/
describe("Doctor reports declared browser contributions", () => {
function contributing(
id: string,
contribution: { capability: string; capabilityRange: string },
): DiscoveredModule {
const base = discoveredModule(id);
return {
...base,
contributions: [
{
artifactPath: `/unpublished/${id}/contributions/slide.js`,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: `${id}.slide`,
version: "1.0.0",
},
],
};
}
function providing(id: string, capability: { id: string; version: string }): DiscoveredModule {
const base = discoveredModule(id);
return {
...base,
manifest: { ...base.manifest, capabilities: [capability] } as DiscoveredModule["manifest"],
};
}
async function detailsFor(modules: readonly DiscoveredModule[]): Promise<readonly string[]> {
const report = await diagnoseEkklesia(
options(),
dependencies({
discoverModules: vi.fn(async () => Object.freeze([...modules])),
discoverSchemas: vi.fn(async () => schemaAggregate(modules)),
}),
);
return check(report, "modules.discovery").details;
}
it("says nothing extra for a module that contributes nothing", async () => {
expect(await detailsFor([discoveredModule("people")])).toEqual(["people 0.1.0"]);
});
it("reports a contribution whose provider satisfies its declared range", async () => {
const details = await detailsFor([
providing("signage", { id: "signage.slide-renderer", version: "1.2.0" }),
contributing("announcements", {
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
}),
]);
expect(details).toEqual([
"signage 0.1.0",
"announcements 0.1.0 · 1 browser contribution (1 with a compatible provider)",
]);
});
it("reports a contribution nobody provides, and one outside its range, as unmatched", async () => {
expect(
await detailsFor([
contributing("announcements", {
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
}),
]),
).toEqual(["announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)"]);
expect(
await detailsFor([
providing("signage", { id: "signage.slide-renderer", version: "3.0.0" }),
contributing("announcements", {
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
}),
]),
).toEqual([
"signage 0.1.0",
"announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)",
]);
});
});
@@ -1,5 +1,5 @@
{ {
"manifestVersion": 6, "manifestVersion": 7,
"id": "db-facade", "id": "db-facade",
"name": "Database Facade Integration Test Module", "name": "Database Facade Integration Test Module",
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.", "description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
@@ -62,6 +62,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
"EKKLESIA_CLIENT_ADDRESS_HEADER", "EKKLESIA_CLIENT_ADDRESS_HEADER",
"EKKLESIA_CLIENT_ADDRESS_MODE", "EKKLESIA_CLIENT_ADDRESS_MODE",
"EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES", "EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES",
"EKKLESIA_DASHBOARD_ORIGIN",
"EKKLESIA_DATABASE_CA", "EKKLESIA_DATABASE_CA",
"EKKLESIA_DATABASE_TLS_MODE", "EKKLESIA_DATABASE_TLS_MODE",
"EKKLESIA_IDENTITY_BASE_URL", "EKKLESIA_IDENTITY_BASE_URL",
@@ -70,6 +71,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
"EKKLESIA_PUBLIC_SIGNUP_ENABLED", "EKKLESIA_PUBLIC_SIGNUP_ENABLED",
"EKKLESIA_SMTP_FROM_ADDRESS", "EKKLESIA_SMTP_FROM_ADDRESS",
"EKKLESIA_SMTP_FROM_NAME", "EKKLESIA_SMTP_FROM_NAME",
"EKKLESIA_SMTP_HELO_NAME",
"EKKLESIA_SMTP_HOST", "EKKLESIA_SMTP_HOST",
"EKKLESIA_SMTP_PASSWORD", "EKKLESIA_SMTP_PASSWORD",
"EKKLESIA_SMTP_PORT", "EKKLESIA_SMTP_PORT",
+5
View File
@@ -20,6 +20,7 @@ const productionEnvironment = Object.freeze({
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material", EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]", EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
EKKLESIA_SMTP_FROM_NAME: "Ekklesia", EKKLESIA_SMTP_FROM_NAME: "Ekklesia",
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
EKKLESIA_SMTP_HOST: "smtp.example.org", EKKLESIA_SMTP_HOST: "smtp.example.org",
EKKLESIA_SMTP_PASSWORD: "sup3r-secret-relay-password", EKKLESIA_SMTP_PASSWORD: "sup3r-secret-relay-password",
EKKLESIA_SMTP_PORT: "587", EKKLESIA_SMTP_PORT: "587",
@@ -67,6 +68,7 @@ describe("host configuration loading", () => {
expect(host.mail).toEqual({ expect(host.mail).toEqual({
fromAddress: "[email protected]", fromAddress: "[email protected]",
fromName: "Ekklesia", fromName: "Ekklesia",
heloName: "mail.example.org",
host: "smtp.example.org", host: "smtp.example.org",
password: "sup3r-secret-relay-password", password: "sup3r-secret-relay-password",
port: "587", port: "587",
@@ -80,6 +82,9 @@ describe("host configuration loading", () => {
}); });
expect(relayByAddress.mail.username).toBeUndefined(); expect(relayByAddress.mail.username).toBeUndefined();
expect(relayByAddress.mail.password).toBeUndefined(); expect(relayByAddress.mail.password).toBeUndefined();
// The SMTP client identity is optional too, and unset is the ordinary case: it matters only
// where the machine's own hostname is not a fully qualified name the relay accepts.
expect(relayByAddress.mail.heloName).toBeUndefined();
// A source-IP-authorized relay has no credentials at all, and that must still validate. // A source-IP-authorized relay has no credentials at all, and that must still validate.
expect(createMailProviderConfig(relayByAddress.mail)).toBeDefined(); expect(createMailProviderConfig(relayByAddress.mail)).toBeDefined();
}); });
+108
View File
@@ -26,6 +26,9 @@ describe("Core identity configuration", () => {
expect(config).toEqual({ expect(config).toEqual({
baseUrl: validBaseUrl, baseUrl: validBaseUrl,
// Unset: the Dashboard is served from the identity base URL, which is the ordinary
// same-origin production shape (ADR 0038).
dashboardOrigin: validBaseUrl,
publicSignupEnabled: false, publicSignupEnabled: false,
secret: validSecret, secret: validSecret,
trustedOrigins: validTrustedOrigins, trustedOrigins: validTrustedOrigins,
@@ -419,3 +422,108 @@ describe("Core identity configuration", () => {
} }
}); });
}); });
describe("Core identity Dashboard origin", () => {
/**
* Where an emailed verification or reset link sends a browser (ADR 0038).
*
* The rules exist because a link in an email is the one artifact nobody inspects before clicking:
* it must land on the deployment's own Dashboard, over https in production, at an origin Better
* Auth will actually accept as a redirect target — and a misconfiguration must fail at startup
* rather than produce a link every recipient is refused at.
*/
it("defaults to the identity base URL, the ordinary same-origin production shape", () => {
expect(createIdentityConfig(validInput()).dashboardOrigin).toBe(validBaseUrl);
expect(createIdentityConfig({ ...validInput(), dashboardOrigin: "" }).dashboardOrigin).toBe(
validBaseUrl,
);
});
it("accepts a separate Dashboard origin that is also a trusted origin", () => {
const config = createIdentityConfig({
...validInput(),
dashboardOrigin: "https://dashboard.example.test",
});
expect(config.dashboardOrigin).toBe("https://dashboard.example.test");
// Separate from the API origin, deliberately: the two are different concepts and a deployment
// may split them.
expect(config.dashboardOrigin).not.toBe(config.baseUrl);
});
it("refuses an origin Better Auth would not accept as a redirect target", () => {
// The failure this prevents is silent and total: Better Auth validates the emailed callback
// against its trusted origins, so an untrusted Dashboard origin breaks every link it sends.
try {
createIdentityConfig({ ...validInput(), dashboardOrigin: "https://elsewhere.example.test" });
throw new Error("expected the untrusted Dashboard origin to be refused");
} catch (error) {
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_UNTRUSTED" });
}
});
it("refuses anything that is not a canonical origin", () => {
for (const value of [
"dashboard.example.test",
"https://dashboard.example.test/",
"https://dashboard.example.test/dashboard",
"https://user:[email protected]",
"https://dashboard.example.test?a=b",
"javascript:alert(1)",
"//dashboard.example.test",
" https://dashboard.example.test",
42,
]) {
try {
createIdentityConfig({ ...validInput(), dashboardOrigin: value });
throw new Error(`expected ${String(value)} to be refused`);
} catch (error) {
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_INVALID" });
}
}
});
it("requires https and a non-loopback host in production, and relaxes both in development", () => {
try {
createIdentityConfig(
// Structural and policy validity are checked before membership, so a plaintext origin is
// reported as plaintext rather than as "untrusted" — which would send an operator looking
// in the wrong place.
{ ...validInput(), dashboardOrigin: "http://dashboard.example.test" },
{ environment: "production" },
);
throw new Error("expected plaintext to be refused in production");
} catch (error) {
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION" });
}
try {
createIdentityConfig(
{ ...validInput(), dashboardOrigin: "https://localhost" },
{ environment: "production" },
);
throw new Error("expected loopback to be refused in production");
} catch (error) {
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION" });
}
const development = createIdentityConfig(
{
...developmentInput(),
dashboardOrigin: "http://localhost:5173",
trustedOrigins: ["http://localhost:3000", "http://localhost:5173"],
},
{ environment: "development" },
);
expect(development.dashboardOrigin).toBe("http://localhost:5173");
});
it("never carries the offending origin in the thrown error", () => {
try {
createIdentityConfig({ ...validInput(), dashboardOrigin: "https://evil.example.test" });
throw new Error("expected a refusal");
} catch (error) {
expect(JSON.stringify({ message: (error as Error).message })).not.toContain("evil");
}
});
});
+269
View File
@@ -0,0 +1,269 @@
import { describe, expect, it, vi } from "vitest";
import {
createIdentityNotifications,
type IdentityNotificationFailure,
} from "../src/identity/notifications.js";
import { MailError, type MailProvider, type ValidatedMailMessage } from "../src/mail/index.js";
/**
* The identity feature's two outbound messages (ADR 0038).
*
* The properties that matter here are all negative ones: that a link is built from configuration
* rather than from anything a request supplied, that a token never reaches a log, that a delivery
* failure never becomes an observable difference between "this address has an account" and "it does
* not", and that identity code learns nothing about SMTP.
*/
const authBaseUrl = "https://api.example.test";
const dashboardOrigin = "https://dashboard.example.test";
const token = "verification-token-abc123";
interface Harness {
readonly sent: ValidatedMailMessage[];
readonly failures: IdentityNotificationFailure[];
readonly notifications: ReturnType<typeof createIdentityNotifications>;
}
function harness(
options: { readonly mail?: MailProvider | undefined; readonly configured?: boolean } = {},
): Harness {
const sent: ValidatedMailMessage[] = [];
const failures: IdentityNotificationFailure[] = [];
const provider: MailProvider = options.mail ?? {
close: async () => undefined,
send: async (message) => {
sent.push(message);
return { messageId: "provider-message-id" };
},
verify: async () => undefined,
};
return {
failures,
notifications: createIdentityNotifications({
authBaseUrl,
dashboardOrigin,
mail: options.configured === false ? undefined : provider,
report: (failure) => failures.push(failure),
}),
sent,
};
}
function only(sent: readonly ValidatedMailMessage[]): ValidatedMailMessage {
expect(sent).toHaveLength(1);
return sent[0] as ValidatedMailMessage;
}
/** Both bodies, so an assertion can never pass because it only looked at the plaintext one. */
function bodies(message: ValidatedMailMessage): string {
return `${message.text ?? ""}\n${message.html ?? ""}`;
}
describe("identity verification email", () => {
it("sends one message to the account's own address, with both bodies", async () => {
const test = harness();
await test.notifications.sendVerificationEmail({
token,
user: { email: "[email protected]", name: "Sam" },
});
const message = only(test.sent);
expect(message.to).toEqual(["[email protected]"]);
expect(message.subject).toBe("Verify your Ekklesia email address");
expect(message.text).toContain("Hello Sam,");
expect(message.html).toContain("<p>Hello Sam,</p>");
expect(bodies(message)).toContain("This link expires in one hour.");
expect(bodies(message)).toContain("you can ignore it");
});
it("builds the link from configuration alone, on the Dashboard origin", async () => {
const test = harness();
await test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } });
const text = only(test.sent).text ?? "";
const link = text.split("\n").find((line) => line.startsWith("https://")) ?? "";
const url = new URL(link);
// Better Auth's own endpoint, so Better Auth remains the only thing that acts on the token.
expect(url.origin).toBe(authBaseUrl);
expect(url.pathname).toBe("/api/auth/verify-email");
expect(url.searchParams.get("token")).toBe(token);
// ...and the destination it will redirect to is the Dashboard, never the API origin.
const callback = new URL(url.searchParams.get("callbackURL") ?? "");
expect(callback.origin).toBe(dashboardOrigin);
expect(callback.searchParams.get("ekklesia_auth")).toBe("verify-email");
});
it("never names the sender, the relay, or any transport detail", async () => {
const test = harness();
await test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } });
// The message contract has no `from` field at all, so identity code cannot choose a sender.
expect(Object.keys(only(test.sent))).not.toContain("from");
const serialized = JSON.stringify(only(test.sent)).toLowerCase();
for (const forbidden of ["smtp", "starttls", "password", "587", "465"]) {
expect(serialized).not.toContain(forbidden);
}
});
});
describe("identity password-reset email", () => {
it("sends one message with a link on Better Auth's reset callback", async () => {
const test = harness();
await test.notifications.sendResetPassword({
token: "reset-token-xyz",
user: { email: "[email protected]", name: "Sam" },
});
const message = only(test.sent);
expect(message.to).toEqual(["[email protected]"]);
expect(message.subject).toBe("Reset your Ekklesia password");
const link = (message.text ?? "").split("\n").find((line) => line.startsWith("https://")) ?? "";
const url = new URL(link);
expect(url.origin).toBe(authBaseUrl);
expect(url.pathname).toBe("/api/auth/reset-password/reset-token-xyz");
const callback = new URL(url.searchParams.get("callbackURL") ?? "");
expect(callback.origin).toBe(dashboardOrigin);
expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password");
});
it("carries no password, hash, or session material", async () => {
const test = harness();
await test.notifications.sendResetPassword({
token: "reset-token-xyz",
user: { email: "[email protected]" },
});
const serialized = bodies(only(test.sent)).toLowerCase();
expect(serialized).not.toContain("hash");
expect(serialized).not.toContain("session");
// It does say the current password still works, which is the reassurance the flow needs.
expect(serialized).toContain("current password stays in place");
});
});
describe("identity notification failures", () => {
it("resolves rather than throwing when the relay fails, and reports a sanitized code", async () => {
const test = harness({
mail: {
close: async () => undefined,
send: async () => {
throw new MailError("MAIL_PROVIDER_UNAVAILABLE");
},
verify: async () => undefined,
},
});
// Resolving is the security property: Better Auth's own `/send-verification-email` rethrows
// whatever this throws, which would make "account exists" observable the moment SMTP broke.
await expect(
test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } }),
).resolves.toBeUndefined();
expect(test.failures).toEqual([
{ kind: "email-verification", reason: "MAIL_PROVIDER_UNAVAILABLE" },
]);
});
it("collapses an unrecognized thrown value rather than forwarding it", async () => {
const test = harness({
mail: {
close: async () => undefined,
send: async () => {
throw new Error("connect ECONNREFUSED 10.40.0.2:587 [email protected] pass=hunter2");
},
verify: async () => undefined,
},
});
await test.notifications.sendResetPassword({ token: "t", user: { email: "[email protected]" } });
expect(test.failures).toEqual([{ kind: "password-reset", reason: "MAIL_SEND_FAILED" }]);
const serialized = JSON.stringify(test.failures);
expect(serialized).not.toContain("hunter2");
expect(serialized).not.toContain("10.40.0.2");
});
it("reports MAIL_NOT_CONFIGURED, and still resolves, when the installation has no mail", async () => {
const test = harness({ configured: false });
await test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } });
await test.notifications.sendResetPassword({ token: "t", user: { email: "[email protected]" } });
expect(test.failures).toEqual([
{ kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" },
{ kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" },
]);
expect(test.sent).toHaveLength(0);
});
it("writes one fixed line by default, carrying neither the address nor the token", async () => {
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const notifications = createIdentityNotifications({
authBaseUrl,
dashboardOrigin,
mail: undefined,
});
await notifications.sendResetPassword({
token: "reset-token-xyz",
user: { email: "[email protected]" },
});
expect(errors).toHaveBeenCalledTimes(1);
const line = String(errors.mock.calls[0]?.[0]);
expect(line).toBe("[identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED");
expect(line).not.toContain("secret-person");
expect(line).not.toContain("reset-token-xyz");
} finally {
errors.mockRestore();
}
});
});
describe("identity notification message safety", () => {
it("escapes a display name rather than letting it close an element", async () => {
const test = harness();
await test.notifications.sendVerificationEmail({
token,
user: { email: "[email protected]", name: 'Sam"><script>alert(1)</script>' },
});
const message = only(test.sent);
expect(message.html).not.toContain("<script>");
expect(message.html).toContain("&lt;script&gt;");
});
it("drops an unusable display name instead of failing the whole message", async () => {
const test = harness();
// A stored name carrying a control character would be refused by the message validator, which
// would turn a cosmetic problem into an undeliverable password reset.
await test.notifications.sendResetPassword({
token: "t",
user: { email: "[email protected]", name: "Sam\r\nBcc: [email protected]" },
});
const message = only(test.sent);
expect(message.text).toContain("Hello,");
expect(message.text).not.toContain("[email protected]");
expect(test.failures).toHaveLength(0);
});
it("refuses to send to an address the mail contract rejects, without throwing", async () => {
const test = harness();
await test.notifications.sendVerificationEmail({
token,
user: { email: "[email protected], [email protected]" },
});
expect(test.sent).toHaveLength(0);
expect(test.failures).toEqual([
{ kind: "email-verification", reason: "MAIL_RECIPIENT_INVALID" },
]);
});
});
+57 -2
View File
@@ -20,6 +20,7 @@ import { identityAdapterSchema } from "../src/identity/schema.js";
function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig { function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
return Object.freeze({ return Object.freeze({
baseUrl: "https://identity.example.test", baseUrl: "https://identity.example.test",
dashboardOrigin: "https://identity.example.test",
publicSignupEnabled: false, publicSignupEnabled: false,
secret: "correct-horse-battery-staple-0123456789", secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: Object.freeze(["https://identity.example.test"]), trustedOrigins: Object.freeze(["https://identity.example.test"]),
@@ -109,18 +110,21 @@ describe("Core identity runtime construction boundary", () => {
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema }); expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
}); });
it("enables exactly email/password (autoSignIn disabled) and rate limiting, nothing else", () => { it("enables exactly email/password, verification, and rate limiting, nothing else", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
createIdentityRuntime({ config: validConfig(), db: fakeDb() }); createIdentityRuntime({ config: validConfig(), db: fakeDb() });
expect(betterAuthMock).toHaveBeenCalledTimes(1); expect(betterAuthMock).toHaveBeenCalledTimes(1);
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>]; const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
// Exhaustive on purpose: a new top-level Better Auth feature has to be added here deliberately,
// never by inheriting a default.
expect(Object.keys(options).sort()).toEqual([ expect(Object.keys(options).sort()).toEqual([
"advanced", "advanced",
"baseURL", "baseURL",
"database", "database",
"emailAndPassword", "emailAndPassword",
"emailVerification",
"logger", "logger",
"onAPIError", "onAPIError",
"rateLimit", "rateLimit",
@@ -133,13 +137,60 @@ describe("Core identity runtime construction boundary", () => {
enabled: true, enabled: true,
maxPasswordLength: 128, maxPasswordLength: 128,
minPasswordLength: 8, minPasswordLength: 8,
// A verified address is a precondition for holding a session (ADR 0038).
requireEmailVerification: true,
resetPasswordTokenExpiresIn: 3600,
// Password reset revokes that human's other sessions, so recovering from a compromise is not
// cosmetic.
revokeSessionsOnPasswordReset: true,
}); });
expect(options.emailVerification).toEqual({
// Following an emailed link never mints a session: mail infrastructure dereferences links.
autoSignInAfterVerification: false,
expiresIn: 3600,
sendOnSignIn: true,
sendOnSignUp: true,
});
// No `customRules`: production inherits Better Auth's own stricter per-path defaults.
expect(options.rateLimit).toEqual({ enabled: true, max: 100, window: 10 }); expect(options.rateLimit).toEqual({ enabled: true, max: 100, window: 10 });
expect(options.baseURL).toBe(validConfig().baseUrl); expect(options.baseURL).toBe(validConfig().baseUrl);
expect(options.secret).toBe(validConfig().secret); expect(options.secret).toBe(validConfig().secret);
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]); expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
}); });
it("wires the injected notifier into both delivery callbacks, and neither without it", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const sendResetPassword = vi.fn(async () => undefined);
const sendVerificationEmail = vi.fn(async () => undefined);
createIdentityRuntime({
config: validConfig(),
db: fakeDb(),
notifications: { sendResetPassword, sendVerificationEmail },
});
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
expect((options.emailAndPassword as Record<string, unknown>).sendResetPassword).toBe(
sendResetPassword,
);
expect((options.emailVerification as Record<string, unknown>).sendVerificationEmail).toBe(
sendVerificationEmail,
);
betterAuthMock.mockClear();
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
const [withoutMail] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
// An installation with no mail configures the identical policy and simply cannot deliver.
// Crucially it does **not** relax `requireEmailVerification`.
expect(withoutMail.emailAndPassword).toMatchObject({ requireEmailVerification: true });
expect(
(withoutMail.emailAndPassword as Record<string, unknown>).sendResetPassword,
).toBeUndefined();
expect(
(withoutMail.emailVerification as Record<string, unknown>).sendVerificationEmail,
).toBeUndefined();
});
it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => { it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
@@ -254,7 +305,7 @@ describe("Core identity runtime construction boundary", () => {
} }
}); });
it("only widens the sign-in/sign-up rate-limit window when a narrow override is explicitly injected", () => { it("only widens the four strict rate-limit windows when a narrow override is explicitly injected", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
createIdentityRuntime({ createIdentityRuntime({
@@ -266,6 +317,10 @@ describe("Core identity runtime construction boundary", () => {
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>]; const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
expect(options.rateLimit).toEqual({ expect(options.rateLimit).toEqual({
customRules: { customRules: {
// The two paths that trigger an email are included, so a test suite driving many recovery
// requests is not throttled by Better Auth's 3-per-60-seconds default for them.
"/request-password-reset": { max: 1000, window: 1 },
"/send-verification-email": { max: 1000, window: 1 },
"/sign-in/email": { max: 1000, window: 1 }, "/sign-in/email": { max: 1000, window: 1 },
"/sign-up/email": { max: 1000, window: 1 }, "/sign-up/email": { max: 1000, window: 1 },
}, },
@@ -0,0 +1,192 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Structural guarantees about email verification and password recovery that no runtime assertion can
* make (ADR 0038).
*
* These read the repository itself, because what matters here is what code *exists* and what depends
* on what: that identity never learns an SMTP detail, that no token store was reimplemented
* alongside Better Auth's, that a device identity has no route into a human email flow, and that
* nothing in a browser bundle can reach any of it.
*/
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
async function readTypeScriptFiles(
directory: string,
): Promise<ReadonlyArray<{ readonly path: string; readonly text: string }>> {
const results: Array<{ path: string; text: string }> = [];
const walk = async (current: string): Promise<void> => {
for (const entry of await readdir(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist") {
continue;
}
await walk(full);
continue;
}
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
results.push({ path: full, text: await readFile(full, "utf8") });
}
}
};
await walk(path.join(repositoryRoot, directory));
return results;
}
/** Strips block comments and whole-line `//` comments: these assertions are about code, and prose
* explaining a rule inevitably quotes the token the rule forbids. */
function withoutComments(text: string): string {
return text
.replaceAll(/\/\*[\s\S]*?\*\//gu, "")
.split("\n")
.filter((line) => !line.trimStart().startsWith("//"))
.join("\n");
}
describe("identity email flows stay above the mail transport", () => {
it("names no SMTP or provider detail anywhere in the identity feature", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
const code = withoutComments(file.text).toLowerCase();
for (const forbidden of [
"nodemailer",
"smtpserver",
"starttls",
"createtransport",
"587",
"465",
]) {
expect(code, `${file.path} names ${forbidden}`).not.toContain(forbidden);
}
}
});
it("reaches the transport only through the provider seam", async () => {
const notifications = await readFile(
path.join(repositoryRoot, "core", "src", "identity", "notifications.ts"),
"utf8",
);
// The whole mail surface identity may use: a validated message and a `send`. Not a config, not
// a transport, not a credential.
expect(notifications).toContain('from "../mail/index.js"');
expect(withoutComments(notifications)).not.toContain("MailProviderConfig");
expect(withoutComments(notifications)).not.toContain("createSmtpMailProvider");
});
});
describe("Better Auth owns every token", () => {
it("adds no Ekklesia table, column, or store for a verification or reset token", async () => {
const schemas = [
path.join("core", "src", "identity", "schema.ts"),
path.join("core", "src", "authorization", "schema.ts"),
path.join("core", "src", "devices", "schema.ts"),
];
for (const relative of schemas) {
const code = withoutComments(
await readFile(path.join(repositoryRoot, relative), "utf8"),
).toLowerCase();
// A parallel token table would be a second expiry clock, a second consumption rule, and a
// second thing to get wrong. `ekklesia_core_verification` is Better Auth's own table.
expect(code, relative).not.toContain("reset_token");
expect(code, relative).not.toContain("verification_token");
expect(code, relative).not.toContain("password_reset");
}
});
it("never hashes, stores, or compares a token itself", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
const code = withoutComments(file.text);
expect(code, file.path).not.toContain("createHash");
expect(code, file.path).not.toContain("timingSafeEqual");
}
});
it("interpolates no token, link, address, or message body into anything it logs", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
const code = withoutComments(file.text);
for (const call of code.matchAll(/console\.\w+\(([^\n]*)/gu)) {
// Fixed prose in a log line is fine; a **substituted value** is what could carry a secret.
// Only the interpolations are examined, so "email was not delivered" passes while
// `${user.email}` could not.
for (const substitution of (call[1] ?? "").matchAll(/\$\{([^}]*)\}/gu)) {
const expression = (substitution[1] ?? "").toLowerCase();
for (const forbidden of ["token", "url", "html", "text", "email", "recipient", "body"]) {
expect(expression, `${file.path} logs ${forbidden}`).not.toContain(forbidden);
}
}
}
}
});
});
describe("managed devices have no part in human email identity", () => {
it("gives a device no address, password, or verification state", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "devices"))) {
const code = withoutComments(file.text).toLowerCase();
for (const forbidden of ["email", "password", "emailverified", "sendresetpassword"]) {
expect(code, `${file.path} names ${forbidden}`).not.toContain(forbidden);
}
}
});
it("keeps the device store out of the identity notification path entirely", async () => {
const notifications = await readFile(
path.join(repositoryRoot, "core", "src", "identity", "notifications.ts"),
"utf8",
);
expect(notifications).not.toContain("devices");
expect(notifications).not.toContain("Device");
});
});
describe("the browser shell reaches none of it", () => {
it("imports neither Better Auth nor Core's mail from any browser source", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
expect(file.text, file.path).not.toContain("better-auth");
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
expect(file.text, file.path).not.toContain("nodemailer");
}
});
it("declares no server identity or mail dependency in the shell package", async () => {
const manifest = JSON.parse(
await readFile(path.join(repositoryRoot, "surfaces", "shell", "package.json"), "utf8"),
) as Record<string, Record<string, string> | undefined>;
const declared = Object.keys({
...(manifest.dependencies ?? {}),
...(manifest.devDependencies ?? {}),
});
expect(declared).not.toContain("better-auth");
expect(declared).not.toContain("nodemailer");
expect(declared).not.toContain("@ekklesia/core");
});
it("never puts a reset token in browser storage", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
const code = withoutComments(file.text);
// A reset token is a bearer credential for one account; the shell holds it in React state for
// the life of one form and nowhere else.
expect(code, file.path).not.toContain("localStorage");
expect(code, file.path).not.toContain("sessionStorage");
expect(code, file.path).not.toContain("document.cookie");
}
});
});
describe("public signup stays gone", () => {
it("offers no registration path from any browser source", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
const code = withoutComments(file.text);
// Email verification is not a licence to self-register (ADR 0038).
expect(code, file.path).not.toContain("/sign-up");
expect(code, file.path).not.toContain("signUp");
}
});
});
+57 -16
View File
@@ -42,30 +42,42 @@ async function readTextFiles(directory: string): Promise<Array<{ path: string; t
} }
describe("outbound email stays server-only", () => { describe("outbound email stays server-only", () => {
it("declares the SMTP dependency in Core alone", async () => { it("declares the SMTP transport in Core alone", async () => {
const core = await readJson("core/package.json"); const core = await readJson("core/package.json");
expect(Object.keys((core.dependencies ?? {}) as object)).toContain("nodemailer"); expect(Object.keys((core.dependencies ?? {}) as object)).toContain("nodemailer");
const mailPackages = (names: readonly string[]): readonly string[] =>
names.filter((name) => /nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name));
// Every package a browser can end up executing, plus the contracts barrel a surface must not // Every package a browser can end up executing, plus the contracts barrel a surface must not
// reach. None may carry a mail transport. // reach. None may carry anything mail-related at all, in either dependency list.
for (const manifest of [ for (const manifest of [
"packages/contracts/package.json", "packages/contracts/package.json",
"packages/module-sdk/package.json", "packages/module-sdk/package.json",
"packages/module-ui-sdk/package.json", "packages/module-ui-sdk/package.json",
"packages/module-ui-host/package.json", "packages/module-ui-host/package.json",
"surfaces/shell/package.json", "surfaces/shell/package.json",
"apps/api/package.json",
]) { ]) {
const parsed = await readJson(manifest); const parsed = await readJson(manifest);
const dependencies = { expect(
...((parsed.dependencies ?? {}) as Record<string, string>), mailPackages([
...((parsed.devDependencies ?? {}) as Record<string, string>), ...Object.keys((parsed.dependencies ?? {}) as object),
}; ...Object.keys((parsed.devDependencies ?? {}) as object),
const mailDependencies = Object.keys(dependencies).filter((name) => ]),
/nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name), manifest,
); ).toEqual([]);
expect(mailDependencies, manifest).toEqual([]);
} }
// The API executable ships no mail package of any kind: it reaches SMTP only through Core's
// provider. Its *test* dependencies may include `smtp-server`, which is a receiving server and
// has no capability to send anything — that is what lets the verification and recovery
// integration suites capture a real message instead of asserting against a fake (ADR 0038).
const api = await readJson("apps/api/package.json");
expect(mailPackages(Object.keys((api.dependencies ?? {}) as object))).toEqual([]);
expect(mailPackages(Object.keys((api.devDependencies ?? {}) as object))).toEqual([
"@types/smtp-server",
"smtp-server",
]);
}); });
it("never imports the transport outside Core's own mail module", async () => { it("never imports the transport outside Core's own mail module", async () => {
@@ -149,13 +161,42 @@ describe("outbound email stays server-only", () => {
}); });
it("hardcodes no provider hostname in Core", async () => { it("hardcodes no provider hostname in Core", async () => {
// Google Workspace is a deployment target, not the architecture. // Google Workspace is a deployment target, not the architecture. It is the deployment that
// exposed the generic `EHLO` defect the HELO-name setting fixes, and it is named nowhere the
// code could come to depend on it.
for (const file of await readTextFiles("core/src/mail")) { for (const file of await readTextFiles("core/src/mail")) {
expect(file.text.toLowerCase(), file.path).not.toContain("gmail"); const text = file.text.toLowerCase();
expect(file.text.toLowerCase(), file.path).not.toContain("smtp-relay"); for (const forbidden of [
expect(file.text.toLowerCase(), file.path).not.toContain("resend"); "gmail",
expect(file.text.toLowerCase(), file.path).not.toContain("brevo"); "smtp-relay",
"googlemail",
"google.com",
"outlook",
"office365",
"sendgrid",
"mailgun",
"postmark",
"resend",
"brevo",
]) {
expect(text, `${file.path} names ${forbidden}`).not.toContain(forbidden);
} }
}
});
it("defaults the SMTP client identity to nothing, so no deployment's name is baked in", async () => {
const config = await readFile(path.join(repositoryRoot, "core/src/mail/config.ts"), "utf8");
const provider = await readFile(
path.join(repositoryRoot, "core/src/mail/smtp-provider.ts"),
"utf8",
);
// The value that fixed a real deployment is configuration, never a constant. A fallback literal
// here would be one church's domain compiled into every installation.
expect(config).not.toContain("riverofglorymn");
expect(provider).not.toContain("riverofglorymn");
// The provider passes it through and supplies nothing of its own when it is absent.
expect(provider).toContain("config.heloName === undefined ? {} : { name: config.heloName }");
}); });
it("never disables TLS certificate verification", async () => { it("never disables TLS certificate verification", async () => {
+110
View File
@@ -193,6 +193,116 @@ describe("host mail configuration", () => {
}); });
}); });
describe("SMTP client identity (HELO name)", () => {
/**
* A real deployment defect, and a generic one.
*
* An SMTP client must greet the server with its own identity. Nodemailer derives that from the
* machine hostname and falls back to the address literal `[127.0.0.1]` when the hostname is not
* fully qualified — the normal state of a container. Some relays close the connection on that
* greeting with a `421` before any message is offered, which reaches a caller as an
* indistinguishable `MAIL_PROVIDER_UNAVAILABLE`. The deployment can now say what to greet as.
*/
it("omits the setting entirely when the deployment configures none", () => {
const config = createMailProviderConfig(validConfig);
// Absent means Nodemailer's own derivation, unchanged. The key is not present at all rather
// than present-and-undefined, so nothing downstream can mistake it for a configured empty name.
expect(config).not.toHaveProperty("heloName");
expect(createMailProviderConfig({ ...validConfig, heloName: "" })).not.toHaveProperty(
"heloName",
);
expect(createMailProviderConfig({ ...validConfig, heloName: undefined })).not.toHaveProperty(
"heloName",
);
});
it.each([
["a two-label domain", "example.org"],
["a subdomain", "mail.example.org"],
["a deep subdomain", "smtp.relay.mail.example.org"],
["hyphens inside labels", "out-bound.example-church.org"],
["digits", "mx1.example2.org"],
["mixed case", "Mail.Example.ORG"],
])("accepts %s", (_label, heloName) => {
expect(createMailProviderConfig({ ...validConfig, heloName })).toMatchObject({ heloName });
});
it.each([
["a scheme", "smtp://example.org"],
["a port", "example.org:587"],
["a path", "example.org/relay"],
["a space", "example org"],
["a leading space", " example.org"],
["a trailing space", "example.org "],
["a carriage return", "example.org\r"],
["a line feed", "example.org\nEHLO evil.example.net"],
["a tab", "example.org\t"],
["a comma", "example.org,evil.example.net"],
["an unqualified single label", "localhost"],
["a machine name with no domain", "ekklesia-host"],
["a bracketed address literal", "[127.0.0.1]"],
["a bare IPv4 address", "192.0.2.1"],
["a trailing dot", "example.org."],
["a leading dot", ".example.org"],
["consecutive dots", "example..org"],
["a leading hyphen in a label", "-example.org"],
["a trailing hyphen in a label", "example-.org"],
["an email address", "[email protected]"],
["prose", "our church mail server"],
["nothing but dots", "..."],
["an over-long name", `${"a".repeat(250)}.example.org`],
])("refuses %s", (_label, heloName) => {
expect(() => createMailProviderConfig({ ...validConfig, heloName })).toThrow(
expect.objectContaining({ code: "MAIL_HELO_NAME_INVALID" }),
);
});
it("refuses an unqualified name, because that is the input the fallback already rejects", () => {
// Configuring a single label would reproduce the very failure this setting exists to fix:
// Nodemailer discards a non-FQDN hostname and substitutes the address literal.
expect(() => createMailProviderConfig({ ...validConfig, heloName: "ekklesia" })).toThrow(
MailConfigError,
);
});
it("never carries the offending value in the error", () => {
try {
createMailProviderConfig({ ...validConfig, heloName: "smtp://secret-internal-name:2525" });
throw new Error("expected a refusal");
} catch (error) {
const serialized = `${(error as Error).message} ${(error as Error).stack ?? ""}`;
expect(serialized).not.toContain("secret-internal-name");
expect(serialized).not.toContain("2525");
}
});
it("is described to an operator, because it is infrastructure rather than a credential", () => {
const described = describeMailProvider(
createMailProviderConfig({
...validConfig,
heloName: "mail.example.org",
password,
username: "[email protected]",
}) as Parameters<typeof describeMailProvider>[0],
);
// It is announced in the clear on every connection, so printing it leaks nothing — and it is
// exactly what an operator needs when a relay has refused the greeting.
expect(described).toContain("HELO mail.example.org");
expect(described).not.toContain(password);
expect(described).not.toContain("[email protected]");
});
it("says nothing about a HELO name when none is configured", () => {
const described = describeMailProvider(
createMailProviderConfig(validConfig) as Parameters<typeof describeMailProvider>[0],
);
expect(described).not.toContain("HELO");
});
});
describe("transactional message validation", () => { describe("transactional message validation", () => {
const message = { const message = {
subject: "Verify your email address", subject: "Verify your email address",
+175 -1
View File
@@ -1,4 +1,4 @@
import type { AddressInfo } from "node:net"; import type { AddressInfo, Socket } from "node:net";
import { createServer, type Server } from "node:net"; import { createServer, type Server } from "node:net";
import { SMTPServer } from "smtp-server"; import { SMTPServer } from "smtp-server";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
@@ -25,6 +25,8 @@ interface CapturedMessage {
readonly from: string; readonly from: string;
readonly to: readonly string[]; readonly to: readonly string[];
readonly data: string; readonly data: string;
/** The argument this client actually sent in `EHLO`, lowercased by the server. */
readonly greetedAs: string;
} }
interface Harness { interface Harness {
@@ -52,6 +54,14 @@ interface HarnessOptions {
readonly rejectRecipients?: readonly string[]; readonly rejectRecipients?: readonly string[];
/** Advertises STARTTLS. Off by default, so a `starttls` client must refuse to continue. */ /** Advertises STARTTLS. Off by default, so a `starttls` client must refuse to continue. */
readonly offerStartTls?: boolean; readonly offerStartTls?: boolean;
/**
* Refuses any client whose `EHLO` argument is not exactly this name, as a relay that requires a
* fully qualified client identity does.
*
* The refusal is a `421`, the same class of response the real relay returned: a temporary failure
* that closes the connection before a message is offered.
*/
readonly requireGreeting?: string;
} }
async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> { async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
@@ -81,11 +91,26 @@ async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
captured.push({ captured.push({
data, data,
from: session.envelope.mailFrom === false ? "" : session.envelope.mailFrom.address, from: session.envelope.mailFrom === false ? "" : session.envelope.mailFrom.address,
greetedAs: session.hostNameAppearsAs,
to: session.envelope.rcptTo.map((entry) => entry.address), to: session.envelope.rcptTo.map((entry) => entry.address),
}); });
callback(); callback();
}); });
}, },
onMailFrom(_address, session, callback) {
if (
options.requireGreeting !== undefined &&
session.hostNameAppearsAs !== options.requireGreeting.toLowerCase()
) {
const error = new Error("Try again later, closing connection.") as Error & {
responseCode?: number;
};
error.responseCode = 421;
callback(error);
return;
}
callback();
},
onRcptTo(address, _session, callback) { onRcptTo(address, _session, callback) {
if (options.rejectRecipients?.includes(address.address)) { if (options.rejectRecipients?.includes(address.address)) {
// 550 is a permanent refusal of this mailbox, which is what a real relay returns. // 550 is a permanent refusal of this mailbox, which is what a real relay returns.
@@ -107,6 +132,64 @@ async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
return { captured, close, port: address.port }; return { captured, close, port: address.port };
} }
/**
* A relay that refuses the greeting itself, byte for byte as the real one did.
*
* `smtp-server` exposes no `EHLO` hook — its earliest usable one is `MAIL FROM`, which
* `transporter.verify()` never reaches — so modelling this failure at the point it actually happens
* needs a socket that speaks the handful of SMTP verbs involved. That matters: the whole reason the
* defect was hard to diagnose is that it aborts the session *before* a message is offered, which is
* also why Doctor's send-nothing probe can see it.
*/
async function startGreetingStrictRelay(expected: string): Promise<Harness> {
const sockets = new Set<Socket>();
const server = createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => undefined);
socket.write("220 relay.invalid ESMTP\r\n");
let buffer = "";
socket.on("data", (chunk: Buffer) => {
buffer += chunk.toString("utf8");
let index = buffer.indexOf("\r\n");
while (index !== -1) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const [verb = "", argument = ""] = line.split(" ");
if (verb.toUpperCase() === "EHLO" || verb.toUpperCase() === "HELO") {
if (argument.toLowerCase() !== expected.toLowerCase()) {
// The exact shape the deployment saw: a 421 naming no reason, then the door shut.
socket.write("421-4.7.0 Try again later, closing connection. (EHLO)\r\n");
socket.write("421 4.7.0 Goodbye\r\n");
socket.end();
return;
}
socket.write("250-relay.invalid\r\n250 OK\r\n");
} else if (verb.toUpperCase() === "QUIT") {
socket.write("221 Bye\r\n");
socket.end();
return;
} else {
socket.write("250 OK\r\n");
}
index = buffer.indexOf("\r\n");
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
const close = async (): Promise<void> => {
// `close()` waits for open sockets, and a refused client may still be holding one.
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve) => server.close(() => resolve()));
};
openHarnesses.push(close);
return { captured: [], close, port: address.port };
}
function config(port: number, overrides: Record<string, unknown> = {}): MailProviderConfig { function config(port: number, overrides: Record<string, unknown> = {}): MailProviderConfig {
const built = createMailProviderConfig( const built = createMailProviderConfig(
{ {
@@ -355,3 +438,94 @@ describe.sequential("live SMTP transport", () => {
expect(harness.captured).toHaveLength(1); expect(harness.captured).toHaveLength(1);
}); });
}); });
describe("SMTP client identity on the wire", () => {
/**
* The regression guard for a real production defect.
*
* A deployment relaying through an IP-authorized relay could not send at all: every attempt
* surfaced as `MAIL_PROVIDER_UNAVAILABLE`. The relay was healthy — `swaks` sent from the same
* machine — and the cause was the greeting. An SMTP client identifies itself in `EHLO`, and with
* nothing configured Nodemailer derives that from the machine hostname, substituting the address
* literal `[127.0.0.1]` whenever the hostname is not fully qualified. The relay closed the
* connection on it with a `421`.
*
* These tests are written against the wire rather than against the transport options, because the
* defect was invisible in the options: everything about the configuration looked correct.
*/
it("greets with the configured name, exactly as configured", async () => {
const harness = await startSmtpServer();
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
await mail.send(
validateMailMessage({ subject: "Greeting", text: "Body.", to: "[email protected]" }),
);
expect(harness.captured[0]?.greetedAs).toBe("mail.example.org");
});
it("never greets as the address literal when a name is configured", async () => {
const harness = await startSmtpServer();
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
await mail.send(
validateMailMessage({ subject: "Greeting", text: "Body.", to: "[email protected]" }),
);
// The exact greeting the relay refused.
expect(harness.captured[0]?.greetedAs).not.toBe("[127.0.0.1]");
});
it("delivers to a relay that demands a fully qualified greeting", async () => {
const harness = await startSmtpServer({ requireGreeting: "mail.example.org" });
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
await mail.send(
validateMailMessage({ subject: "Accepted", text: "Body.", to: "[email protected]" }),
);
expect(harness.captured).toHaveLength(1);
expect(harness.captured[0]?.greetedAs).toBe("mail.example.org");
});
it("is refused by that same relay when no name is configured", async () => {
const harness = await startGreetingStrictRelay("mail.example.org");
const mail = provider(config(harness.port));
// Deterministic regardless of what this machine is called: the server accepts exactly one name,
// and Nodemailer's derivation cannot produce `mail.example.org` on any host.
await expect(
mail.send(validateMailMessage({ subject: "Refused", text: "Body.", to: "[email protected]" })),
).rejects.toMatchObject({ code: "MAIL_PROVIDER_UNAVAILABLE" });
expect(harness.captured).toHaveLength(0);
});
it("carries the refused greeting into no error a caller can see", async () => {
const harness = await startGreetingStrictRelay("mail.example.org");
const mail = provider(config(harness.port));
const failure = await mail
.send(validateMailMessage({ subject: "Refused", text: "Body.", to: "[email protected]" }))
.catch((error: unknown) => error);
// A `421` is SMTP's "try later", so it stays `MAIL_PROVIDER_UNAVAILABLE` rather than becoming a
// permanent rejection — and the server's response text never reaches the caller.
const serialized = `${(failure as Error).message} ${(failure as Error).stack ?? ""}`;
expect(serialized).not.toContain("Try again later");
expect(serialized).not.toContain("closing connection");
});
it("still verifies through the same greeting Doctor's probe will use", async () => {
const harness = await startGreetingStrictRelay("mail.example.org");
const configured = provider(config(harness.port, { heloName: "mail.example.org" }));
const unconfigured = provider(config(harness.port));
// Doctor builds its provider from the identical validated configuration, so an operator whose
// relay refuses the greeting sees it in `pnpm doctor` rather than only when a reset email
// silently fails to arrive.
await expect(configured.verify()).resolves.toBeUndefined();
await expect(unconfigured.verify()).rejects.toMatchObject({
code: "MAIL_PROVIDER_UNAVAILABLE",
});
});
});
@@ -17,7 +17,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -27,6 +27,7 @@ function fakeModule(
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -193,7 +194,7 @@ afterAll(async () => {
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") { function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
return parseModuleManifest({ return parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+8 -8
View File
@@ -21,7 +21,7 @@ function manifestWith(
id = "announcements", id = "announcements",
) { ) {
return parseModuleManifest({ return parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => { it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => { it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => { it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
], ],
])("rejects %s", (_label, declaration) => { ])("rejects %s", (_label, declaration) => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
"valueOf", "valueOf",
])("rejects the reserved object property name %s as a configuration key", (key) => { ])("rejects the reserved object property name %s as a configuration key", (key) => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
"", "",
])("rejects the malformed configuration key %s", (key) => { ])("rejects the malformed configuration key %s", (key) => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+177 -1
View File
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> { function manifestFor(id: string): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -957,3 +957,179 @@ describe("module discovery", () => {
}); });
}); });
}); });
/**
* A contribution artifact is resolved through the identical loop as every other compiled entrypoint
* (ADR 0041), so it inherits every protection `entrypoints.ui` already had rather than receiving a
* second, weaker implementation of them.
*/
describe("browser contribution artifact resolution", () => {
function contributingManifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
...manifestFor("announcements"),
contributions: [
{
id: "announcements.signage-slide",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/signage-slide/index.js",
...overrides,
},
],
};
}
it("resolves a declared contribution to a canonical path inside the package", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", {
extraFiles: { "dist/contributions/signage-slide/index.js": "export default {};" },
manifest: contributingManifest(),
});
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const contribution = modules[0]?.contributions[0];
expect(contribution).toMatchObject({
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
id: "announcements.signage-slide",
version: "1.0.0",
});
expect(contribution?.artifactPath.endsWith("dist/contributions/signage-slide/index.js")).toBe(
true,
);
expect(path.isAbsolute(contribution?.artifactPath ?? "")).toBe(true);
});
it("reports an empty contribution list for a module that contributes nothing", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "calendar");
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(modules[0]?.contributions).toEqual([]);
});
it("rejects a missing contribution artifact", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", { manifest: contributingManifest() });
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ code: "MODULE_ENTRYPOINT_MISSING" }),
]),
});
});
it("rejects a contribution artifact that is not compiled JavaScript", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", {
extraFiles: { "src/contributions/signage-slide/index.tsx": "export default {};" },
manifest: contributingManifest({ artifact: "./src/contributions/signage-slide/index.tsx" }),
});
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ code: "MODULE_ENTRYPOINT_INVALID" }),
]),
});
});
it("rejects a contribution artifact that escapes the package through a link", async () => {
const modulesDirectory = await createModulesDirectory();
const outside = await createModulesDirectory();
await writeFile(path.join(outside, "elsewhere.js"), "export default {};");
await createModule(modulesDirectory, "announcements", {
manifest: contributingManifest(),
});
await mkdir(path.join(modulesDirectory, "announcements/dist/contributions/signage-slide"), {
recursive: true,
});
await symlink(
path.join(outside, "elsewhere.js"),
path.join(modulesDirectory, "announcements/dist/contributions/signage-slide/index.js"),
);
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({
code: "MODULE_ENTRYPOINT_INVALID",
path: "contributions.0.artifact",
}),
]),
});
});
it("rejects two contributions that resolve through a link to the same file", async () => {
const modulesDirectory = await createModulesDirectory();
const manifest = {
...manifestFor("announcements"),
contributions: [
{
id: "announcements.first",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/first/index.js",
},
{
id: "announcements.second",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/second/index.js",
},
],
};
await createModule(modulesDirectory, "announcements", {
extraFiles: { "dist/contributions/first/index.js": "export default {};" },
manifest,
});
await mkdir(path.join(modulesDirectory, "announcements/dist/contributions/second"), {
recursive: true,
});
await symlink(
path.join(modulesDirectory, "announcements/dist/contributions/first/index.js"),
path.join(modulesDirectory, "announcements/dist/contributions/second/index.js"),
);
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ code: "MODULE_ENTRYPOINT_INVALID", path: "contributions" }),
]),
});
});
});
@@ -181,6 +181,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -199,7 +200,7 @@ afterAll(async () => {
* by eligibility resolution. */ * by eligibility resolution. */
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule { function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -208,6 +209,7 @@ function fakeModule(id: string, dependencies?: Record<string, unknown>): Discove
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
+3 -2
View File
@@ -22,7 +22,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -36,6 +36,7 @@ function fakeModule(
}, },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
@@ -343,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -65,7 +65,7 @@ function manifestFor(
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+2 -1
View File
@@ -28,7 +28,7 @@ interface PermissionInput {
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */ * is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule { function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -41,6 +41,7 @@ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): D
}, },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
+1 -1
View File
@@ -31,7 +31,7 @@ function manifest(
permissions: readonly Record<string, string>[] = [], permissions: readonly Record<string, string>[] = [],
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+2 -1
View File
@@ -57,7 +57,7 @@ function manifestFor(
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -861,6 +861,7 @@ describe("module HTTP routes", () => {
expect(runtime.routes).toHaveLength(3); expect(runtime.routes).toHaveLength(3);
for (const route of runtime.routes) { for (const route of runtime.routes) {
expect(Object.keys(route).sort()).toEqual([ expect(Object.keys(route).sort()).toEqual([
"bodyMode",
"handler", "handler",
"maxBodyBytes", "maxBodyBytes",
"method", "method",
+1 -1
View File
@@ -41,7 +41,7 @@ function manifestFor(
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {}, options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+2 -1
View File
@@ -18,7 +18,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -29,6 +29,7 @@ function fakeModule(
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
+152 -1
View File
@@ -29,6 +29,15 @@ interface ModuleFixture {
readonly ui?: string; readonly ui?: string;
/** A dedicated full-viewport artifact, independent of the primary UI. */ /** A dedicated full-viewport artifact, independent of the primary UI. */
readonly display?: string; readonly display?: string;
/** Capabilities this module provides — what another module's contributions can target. */
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
/** Browser renderers this module contributes to another module's capability. */
readonly contributions?: readonly {
readonly localName: string;
readonly version: string;
readonly capability: string;
readonly capabilityRange: string;
}[];
} }
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> { async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
@@ -61,8 +70,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
}, },
}), }),
}, },
capabilities: fixture.capabilities ?? [],
contributions: (fixture.contributions ?? []).map((contribution) => ({
artifact: `./dist/contributions/${contribution.localName}/index.js`,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: `${fixture.id}.${contribution.localName}`,
version: contribution.version,
})),
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: permissions:
fixture.display === undefined fixture.display === undefined
@@ -89,6 +106,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display); await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
} }
for (const contribution of fixture.contributions ?? []) {
const contributionDirectory = path.join(
moduleDirectory,
"dist/contributions",
contribution.localName,
);
await mkdir(contributionDirectory, { recursive: true });
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};");
}
} }
/** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that /** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that
@@ -294,3 +320,128 @@ describe("module display UI publication registry", () => {
expect(registry.listDisplays()).toBe(registry.listDisplays()); expect(registry.listDisplays()).toBe(registry.listDisplays());
}); });
}); });
/**
* The generic contribution mechanism (ADR 0041), exercised through the exact relationship it exists
* for and with no platform knowledge of what any of it means: `signage` provides a capability,
* `announcements` contributes a renderer to it, and neither module imports the other.
*/
describe("module contribution publication", () => {
const signage = {
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
id: "signage",
};
const contributor = (capabilityRange: string) => ({
contributions: [
{
capability: "signage.slide-renderer",
capabilityRange,
localName: "signage-slide",
version: "1.0.0",
},
],
id: "announcements",
});
it("publishes a contribution whose target capability is provided within range", async () => {
const modules = await discover([signage, contributor("^1.0.0")]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.listContributions()).toHaveLength(1);
expect(
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
).toMatchObject({
capability: "signage.slide-renderer",
// The provider's declared version, so the range was checked against something real.
capabilityVersion: "1.2.0",
contributionId: "announcements.signage-slide",
moduleId: "announcements",
version: "1.0.0",
});
});
it("serves exactly the canonical artifact path discovery validated", async () => {
const modules = await discover([signage, contributor("^1.0.0")]);
const announcements = modules.find((module) => module.manifest.id === "announcements");
const registry = createModuleUiPublicationRegistry(modules);
const published = registry.resolveContribution(
"announcements",
"0.1.0",
"announcements.signage-slide",
);
expect(published?.artifactPath).toBe(announcements?.contributions[0]?.artifactPath);
expect(path.isAbsolute(published?.artifactPath ?? "")).toBe(true);
});
it("withholds a contribution whose capability no installed module provides", async () => {
const modules = await discover([contributor("^1.0.0")]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.listContributions()).toEqual([]);
expect(
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
).toBeUndefined();
});
it("withholds a contribution whose provider is outside its declared compatible range", async () => {
// Fails closed: an incompatible renderer is absent rather than mounted and broken.
const modules = await discover([signage, contributor("^2.0.0")]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.listContributions()).toEqual([]);
expect(registry.contributionsForModule("signage")).toEqual([]);
});
it("gives a consuming module exactly the contributions aimed at its own capabilities", async () => {
const modules = await discover([
signage,
{ capabilities: [{ id: "calendar.week-view", version: "1.0.0" }], id: "calendar" },
contributor("^1.0.0"),
]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.contributionsForModule("signage").map((entry) => entry.contributionId)).toEqual(
["announcements.signage-slide"],
);
// A module that provides a different capability sees nothing, and the *contributing* module
// does not see its own contribution: it provides no capability it was aimed at.
expect(registry.contributionsForModule("calendar")).toEqual([]);
expect(registry.contributionsForModule("announcements")).toEqual([]);
});
it("fails a lookup closed for an unknown, mismatched, or non-string identity", async () => {
const modules = await discover([signage, contributor("^1.0.0")]);
const registry = createModuleUiPublicationRegistry(modules);
expect(
registry.resolveContribution("announcements", "9.9.9", "announcements.signage-slide"),
).toBeUndefined();
expect(
registry.resolveContribution("signage", "0.1.0", "announcements.signage-slide"),
).toBeUndefined();
expect(
registry.resolveContribution("announcements", "0.1.0", "announcements.nope"),
).toBeUndefined();
expect(registry.resolveContribution(7, "0.1.0", "announcements.signage-slide")).toBeUndefined();
expect(registry.contributionsForCapability(undefined)).toEqual([]);
});
it("carries no domain vocabulary at all", async () => {
const modules = await discover([signage, contributor("^1.0.0")]);
const registry = createModuleUiPublicationRegistry(modules);
const published = registry.listContributions()[0];
// Core knows identity, version, compatibility, and one path. There is deliberately no payload
// schema, slot, ordering, or rendering hint — what a renderer draws is not the platform's.
expect(Object.keys(published ?? {}).sort()).toEqual([
"artifactPath",
"capability",
"capabilityVersion",
"contributionId",
"moduleId",
"moduleVersion",
"version",
]);
});
});
+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 owner@example.org
```
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 [email protected]` |
| 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

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