145 Commits
Author SHA1 Message Date
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
The_River 3581ad7c8e Merge pull request 'fix: give Doctor the host configuration the runtime receives' (#37) from phase-7c/doctor-config-parity into main
CI / verify (push) Successful in 41s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 1m24s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/37
2026-08-25 00:20:51 +00:00
ekklesiaandClaude Opus 5 2b4f0afb72 fix: give Doctor the host configuration the runtime receives
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 14s
CI / migration-smoke (pull_request) Successful in 1m24s
`pnpm doctor` and `startApi` each read their own subset of the same
environment variables, and the subsets had drifted. Doctor never read
`S3_*` or module secrets at all, so a correctly configured installation
was told its object storage was "not configured" and every module
requiring a bucket or a secret was reported blocked -- while the runtime,
from the identical environment, bound those buckets and ran the modules.

Both executables now map the environment through one
`loadHostConfiguration(env)` in Core, which takes the environment as an
argument and never reads `process.env` itself. Validation stays with the
existing per-domain validators, so each caller keeps its own failure
behaviour: startup refuses a rejected value, Doctor reports it as a
sanitized code.

Doctor's storage option becomes raw input like its mail option, so a
half-configured provider is reported as a blocking configuration failure
instead of throwing before a report exists. The outbound-email check --
computed, counted toward the result, but absent from every rendered
section -- is now printed, and the rendered set is asserted against a
real report so a future check cannot go missing the same way.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 19:15:49 -05:00
The_River a835215eb8 Merge pull request 'feat: add a host-provided outbound email transport' (#36) from phase-7c/outbound-email into main
CI / verify (push) Successful in 40s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m24s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/36
2026-08-24 23:46:22 +00:00
ekklesiaandClaude Opus 5 fa3021e49b feat: add a host-provided outbound email transport
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m23s
Ekklesia could authenticate people and pair devices but could not send a
single message. Better Auth has been configured with emailAndPassword
since ADR 0011 and neither of its mail hooks — sendResetPassword and
emailVerification.sendVerificationEmail — was ever implemented, so email
verification, password recovery, and invitations were all blocked on the
same missing thing.

That missing thing is a transport, not a Google Workspace integration.
Email is now a host-provided infrastructure service with a provider seam,
shaped exactly like object storage: feature code says "send this
transactional message" and the provider owns the host, port, TLS,
credentials, connection behaviour, and every transport error. No
provider's hostname appears anywhere in Core, and a test asserts it.
Google Workspace SMTP Relay is the expected first deployment and a
compatibility target — the same variables address Microsoft 365, a
transactional provider, or a local Postfix.

The contract is deliberately tiny: one recipient or a small bounded list,
a subject, text and/or HTML, an optional reply-to, and nothing else. There
is no `from` field at all — the sender is host configuration, so no
feature and no future module can choose who mail appears to come from,
which is how a platform otherwise acquires an impersonation primitive.
Control characters are refused rather than stripped in addresses, display
names, subjects, and reply-to values; writing that rule down was not
enough, because the first implementation trimmed an address before
checking it and silently accepted a trailing carriage return until a test
caught it.

Security modes are named rather than inferred from a boolean and a port,
which is genuinely ambiguous: `secure: false` on 587 means "connect in the
clear and hope STARTTLS is offered". `starttls` sets requireTLS so a
stripped upgrade fails the send instead of continuing in plaintext,
production refuses `plaintext` outright, and certificate verification has
no off switch. Credentials are optional as a pair, because a relay
authorized by source IP has neither and requiring them would make the
intended production arrangement unconfigurable; half a pair fails loudly.

Connection, greeting, and socket timeouts are all explicit — the
object-storage pass already showed what a network client on library
defaults does to a request. Nothing retries: a failure after the message
data has gone out is ambiguous, and retrying it turns one password-reset
request into several emails, so this is a single bounded attempt and
deliberately not an exactly-once promise.

Email stays optional. An installation with no SMTP host is a complete
deployment, nothing sends at startup, and constructing the transport opens
no socket. There is no POST /api/v1/send-email and no route that touches
the provider — an authenticated send endpoint is an open relay with extra
steps. Modules get nothing either; services are added one at a time when
something real needs one, and nothing does yet.

Proven against a real in-process SMTP server rather than a mock: delivery
with the host's sender on the wire, authentication and its sanitized
refusal, a rejected mailbox distinguished from an outage, a refused
connection, a silent server hitting its deadline, and a starttls transport
refusing to continue when the upgrade is not offered. No test sends real
email and no live credential exists in this repository or in CI.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 18:37:16 -05:00
The_River ba32499456 Merge pull request 'feat: give display devices their own paired identity' (#35) from phase-7b/device-pairing into main
CI / verify (push) Successful in 40s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m23s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/35
2026-08-24 05:28:41 +00:00
ekklesiaandClaude Opus 5 80189c8ae2 feat: give display devices their own paired identity
CI / verify (pull_request) Successful in 40s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m22s
ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page.
Together, the only way to light a television in a foyer was to sign a
human being into it and leave that session running on a screen anyone can
walk up to: a person's credentials on a wall, a person's session as the
thing signage depends on, and the display going dark when they are
revoked or their session simply expires.

A screen is not a person. Ekklesia now recognizes three principal classes
— anonymous, authenticated human, and managed device — and a device is a
first-class principal with its own credential and its own explicit
permissions. It has no email, no password, and no row in the identity
tables, and it satisfies the `authenticated` baseline never, so every
route that settles for "somebody is signed in" stays closed to it.

Device permissions live in their own table because
ekklesia_core_grant.user_id is a non-null foreign key into the user table
and making it polymorphic would rewrite the platform's central
authorization record to serve one narrow case. That is a schema decision,
not a second decision point: the evaluator gained one branch, so a device
is allowed or denied by the same function, with the same deny-by-default,
as anyone else — with no owner path it could reach and no baseline that
stands in for a grant.

The six-digit code is not the credential. It is printed across a screen in
a public hallway, so it is treated as public: it identifies a request to
an administrator and is worth nothing alone. Collecting the credential
also requires a 256-bit claim secret held only in the requesting browser's
HttpOnly cookie, and approving requires the restricted core.devices.manage
permission. Without that split, reading a code off a wall would hand over
the screen's identity.

The permission a pairing asks for is read from the module's validated
display declaration, never from the request, so a browser cannot ask to be
granted core.devices.manage or another module's permission. Approval
grants exactly that one permission.

Credentials are stored only as SHA-256; the raw value exists in the
Set-Cookie that issues it and the device's own cookie jar, and is never
logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so
the module display code running in that same page cannot read it.
Revocation is a server-side column checked on every principal resolution,
and re-pairing mints a new identity so an old credential can never come
back. State transitions are conditional updates, so one code cannot be
approved twice or claimed into two credentials under a race.

/api/v1/principal still reports a device as anonymous: it is what the
Dashboard uses to decide whether a person is signed in. Displays read
their viewer from the descriptor instead, which already performed the
authorization — so displays are descriptor-first while the Dashboard stays
principal-first, and neither infers an identity by elimination.

Proven end to end against real PostgreSQL and real HTTP: an unpaired
screen shows a code, an owner approves it, the device opens the display
descriptor and a realtime stream, and revocation closes both while the
device still holds its cookie. An already-open SSE stream is bounded by
realtime's existing five-minute reconnect rather than closed instantly,
and that bound is documented rather than called immediate.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 23:41:03 -05:00
The_River 3ce4fe03fe Merge pull request 'feat: create the initial platform owner from an operator command' (#34) from phase-7b/initial-owner-bootstrap into main
CI / verify (push) Successful in 41s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m18s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/34
2026-08-24 03:44:19 +00:00
ekklesiaandClaude Opus 5 c8fa13dd6a feat: create the initial platform owner from an operator command
CI / verify (pull_request) Successful in 41s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m18s
A fresh production installation deadlocked. ADR 0034 gave Ekklesia a login
page and ADR 0019 gave it a single-use platform-owner bootstrap, but that
bootstrap grants ownership to a user id that already exists and has no way
to bring one into being. Public signup is disabled by default and
production cannot enable it at all, so a newly deployed instance had a
login page, no account, and no supported way to create one — only hand-
written SQL or briefly opening public registration.

    pnpm identity:create-initial-owner -- --email [email protected]

creates the identity and grants it ownership, in the same shape as the
other operator commands: trusted local tooling, reachable only by someone
who already holds the database credentials, serving no HTTP route. No
/signup, /register, or /setup route is added, no bootstrap endpoint, and
no setup token; public signup stays disabled in every environment and a
test asserts a freshly bootstrapped deployment still refuses
/api/auth/sign-up/email.

The identity is created through Better Auth's own sign-up path, so
hashing, salting, normalization and the 8-128 bound are the identity
system's rather than a second implementation that could drift. Because the
server's instance refuses account creation, the command builds its own
in-process instance; nothing in apps/api imports it and the running
server's posture is untouched.

The sign-up result is verified rather than trusted. Better Auth returns a
fabricated success for an already-registered address — a user object whose
id was never persisted, database unchanged. That is correct enumeration
defence on a public endpoint and a trap here, so the identity is always
read back from the database and that read, not the response, is what the
grant uses.

An existing usable identity is adopted: ownership is granted and its
password left alone, reported as an adoption rather than a creation. This
is the recovery path for the one partial failure possible, since sign-up
commits on Better Auth's connection and the grant is a separate
transaction. A credential-less row is refused instead — making it an owner
would consume the single use on an identity nobody can authenticate as. A
password is never reset and an account is never taken over.

Whole invocations are serialized by an advisory lock on their own key, so
a race cannot leave a stray signable-in account behind; the grant's own
locked single-use transaction is unchanged. There is no --force or reset.

The password is prompted for invisibly or read from stdin, never from
argv (--password is refused with an explanation) and never from an
environment variable, so nothing about it becomes permanent deployment
configuration. The command needs DATABASE_URL and nothing else — verified
that sign-up needs no secret, base URL, or trusted origins, and that the
resulting account signs in through a separately configured server — so the
first owner can be created before TLS or DNS exist. It never migrates.

Proven end to end rather than at the database: a fresh installation is
bootstrapped, signed in through the real browser login transport with
signup disabled, its principal read, and used to open an owner-protected
resource that stays closed to anonymous callers.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 22:32:53 -05:00
The_River c131543abf Merge pull request 'feat: add browser auth shell' (#33) from phase-7b/browser-auth-shell into main
CI / verify (push) Successful in 38s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m8s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/33
2026-08-24 03:06:01 +00:00
ekklesia a8c7c50b4d feat: add browser auth shell
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m0s
2026-08-23 22:01:26 -05:00
The_River 91aa9c2930 Merge pull request 'feat: add display kiosk host' (#32) from phase-7a/display-kiosk-host into main
CI / verify (push) Successful in 39s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m2s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/32
2026-08-24 01:54:10 +00:00
ekklesia 2e0fe059c3 feat: add display kiosk host
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s
2026-08-23 20:50:56 -05:00
The_River be5ed7853d Merge pull request 'feat: add native module realtime transport' (#31) from phase-7a/native-realtime-transport into main
CI / verify (push) Successful in 37s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 1m0s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/31
2026-08-23 23:49:48 +00:00
ekklesia fc6c8045c4 feat: add native module realtime transport
CI / verify (pull_request) Successful in 37s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m7s
2026-08-23 18:47:18 -05:00
The_River fcc531a39c Merge pull request 'feat: deliver module objects directly to browsers through opaque read URLs' (#30) from phase-7a/object-delivery into main
CI / verify (push) Successful in 36s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m4s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/30
2026-08-23 22:17:07 +00:00
ekklesiaandClaude Opus 5 7ff063b87a feat: deliver module objects directly to browsers through opaque read URLs
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 56s
ADR 0030 left exactly one way to get an image to a browser: read the
object into the module's process and write it back out of a module HTTP
route. Every byte of every asset travelled storage -> Ekklesia -> browser.
For the slideshow this platform exists to replace that means Ekklesia in
the path of every image on every display, permanently, for bytes that
never change — and it would make Ekklesia responsible for re-implementing
byte ranges, without which video seeking cannot work at all.

A module can now ask a logical bucket for a temporary browser URL:

    const { url, expiresAt } = await assets.createReadUrl("slides/welcome.png");

The contract names intent, not mechanism. There is no signing, expiry
format, endpoint, or bucket in it — a host with no S3 anywhere could
satisfy the same method, which is what keeps a future single-module
standalone host able to implement it. The URL is opaque to module code and
a bearer capability to whoever holds it, so a module authorizes the
request in its own route before asking: storage bounds reach, never
identity. Lifetimes run one minute to six hours, fifteen by default, and
out-of-range values are refused rather than clamped, so "expires forever"
is unreachable. Logical keys stay canonical state; URLs are generated on
demand and never persisted or logged.

The provider endpoint and the browser delivery origin are separated
because measurement showed they cannot be collapsed: an S3-compatible
signature covers the request Host, so a URL signed for one origin and
rewritten to another is rejected. The browser-facing origin is therefore a
signing input (S3_DELIVERY_ENDPOINT), defaulting to S3_ENDPOINT — so the
standard deployment configures nothing and delivery just works. The scheme
is not signed, so a reverse proxy may terminate TLS provided it passes the
original Host through; that is pinned by a test rather than described and
hoped for.

Read URLs are stable within a window of one third of their lifetime.
Signing is a pure function of the clock, so naive re-signing yields a new
URL per second — a guaranteed browser cache miss, and the reference client
keys its <img> by URL, so an unchanged image would be torn down and
re-downloaded continuously. Quantizing the signing timestamp makes
repeated calls byte-identical while leaving at least two-thirds of the
lifetime; Cache-Control is set at signing time to the guaranteed
remainder, because the pinned SeaweedFS image sends none of its own.

Verified against live SeaweedFS: byte ranges answered directly with 206
and a correct Content-Range, so no range proxy is needed in Core; signing
is local (200 URLs in 66ms), so no batch API is needed; unsigned requests
for the same object are refused, so delivery never depends on a public
bucket; tampered signatures, swapped keys, and swapped buckets all
rejected. Signed write and delete URLs are deliberately not built.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 17:12:07 -05:00
The_River 9a3b8dab8f Merge pull request 'feat: add host-provided module object storage' (#29) from phase-7a/module-storage into main
CI / verify (push) Successful in 37s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 59s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/29
2026-08-23 21:40:12 +00:00
ekklesiaandClaude Opus 5 af4a8009fb fix: bound provider exchanges and stop the storage suite starving its server
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 51s
The live storage suite failed in CI with three failures and a 30s runner
timeout while passing locally in under three seconds. Two independent
defects produced that, one of them a real runtime defect.

Provider calls had no deadline. `S3Client` was built with no
`requestHandler` and no `maxAttempts`, and the SDK's `connectionTimeout`
and `socketTimeout` both default to 0 — disabled. A provider that accepts
a connection and then goes silent therefore held the caller open forever,
bounded in the suite only by vitest and in production by nothing at all.
ADR 0030 promises such a failure reaches a module as the transient
`STORAGE_PROVIDER_UNAVAILABLE` it retries through; `translateStorageError`
already mapped the SDK's `TimeoutError` onto that code, but the branch was
unreachable because nothing ever timed out. The bound is on socket
idleness rather than total elapsed time so a module streaming a large
object is never cut off for being big, only for going quiet.

The suite provisioned a fresh bucket per test. Every S3 bucket becomes its
own SeaweedFS collection claiming a block of volumes from a budget the
server derives from free disk, and `mini` auto-configures a
production-shaped ~1GB volume — so the ceiling is small and varies with
the runner. Twelve buckets per run exhausted it, and an exhausted budget
is not a failed write: the master retries allocation internally and never
replies, which is the hang CI saw. Tests now share one bucket and isolate
by key namespace, which exercises prefix scoping at the same time;
bucket identity is provisioned only where it is the thing under test.
Pinning a small volume size makes the budget depend far less sharply on
the host's disk.

Verified by starving a server on purpose: the same exhaustion that used to
hang until the runner killed it now fails in ~15s with a structured error
and no runner timeout, so the failure is bounded rather than hidden. Ten
consecutive runs pass in ~2.1s each, using 28 volumes per run against a
5212 budget where the old suite used 84 against 161.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 14:54:58 -05:00
ekklesiaandClaude Opus 5 b9ca0d560d feat: add host-provided module object storage
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Failing after 2m20s
`manifest.storage.buckets` has existed since manifest version 1 and meant
nothing — discovery validated it and no code read it. This turns it into a
real capability, which is what stands between Announcements and the module
system: that product currently fetches a hardcoded LAN S3 endpoint, parses
ListObjectsV2 XML by hand, and hands the browser direct object URLs.

- A module declares a logical bucket and receives a scoped facade at
  `context.storage` — list, head, get, put, delete. The contract names no
  provider: no endpoint, region, credential, or physical bucket is reachable
  from it, which is what lets the same module run under a future standalone
  host over its own or a shared S3-compatible service.
- Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the
  deployment target (ADR 0004), never an API name. Probing the pinned image
  found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's
  `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would
  have failed every second boot. A live test pins it.
- Logical → physical mapping is deterministic and collision-safe: an
  installation prefix, the module and bucket ids joined by `--` (which cannot
  occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide),
  and a digest over the full untruncated identity. Two modules declaring
  `assets` always get separate namespaces; neither can see the physical name.
- Declaring a bucket is now a requirement: with no provider configured the
  module is `blocked · storage_unavailable` through ADR 0028's existing
  resolver, propagating to required dependents. An installation with no object
  storage stays a complete deployment.
- Binding availability is not provider health. Eligibility asks whether
  storage is configured, never whether it answered; bucket creation is lazy on
  first use so a storage outage cannot stop the platform booting; a running
  module whose store blinks stays running and retries. Nothing deletes a
  bucket.
- Credentials are deployment-supplied, never persisted, never routed through
  module configuration, and never visible to modules, module UI, diagnostics,
  or API responses. Provider errors translate to a closed code set carrying no
  endpoint, bucket, request id, or XML.
- Retires ADR 0029's transitional `bucket` configuration key. No manifest
  version change. The template declares no bucket, so it still runs without S3.

See ADR 0030.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 08:45:53 -05:00
The_River 527239f6de Merge pull request 'feat: add the module configuration and secrets contract' (#28) from phase-7a/module-configuration into main
CI / verify (push) Successful in 36s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 46s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/28
2026-08-23 02:51:36 +00:00
ekklesiaandClaude Opus 5 baa689b2a7 feat: add the module configuration and secrets contract
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 46s
Modules had no way to be told anything. Announcements — the first real
product Ekklesia will absorb — has zero environment variables and compiles
a LAN IP address into its source, so this is the contract standing between
it and the module system.

- A module declares what it needs in its manifest, using six bounded types
  (string, integer, boolean, enum, url, secret) with no nesting and no
  regular expressions. Static declaration is what lets the platform validate
  configuration *before* deciding whether a module may start: an enabled
  module missing a required value is reported `blocked ·
  configuration_missing · <key>` rather than taking startup down from inside
  its own `start` hook, and Doctor reports the same while still importing no
  module code. That forces `manifestVersion: 5`, a hard cutover.
- Ordinary values persist in one Core table (migration 0005), one row per
  (module_id, key), stored as text until validated against the declaring
  module's current manifest. Nothing is coerced; an invalid stored value
  blocks the module rather than falling back to the default. A key the
  manifest no longer declares is retained and ignored.
- Secrets are never persisted. There is no encryption at rest in this
  repository, so a `secret` key is deployment-supplied and mapped in by the
  host under a name the module never sees. A secret declaration has no
  `default` field, the write API refuses secret material, and the HTTP view
  has no `value` field for one — absent by construction, not filtered.
- Modules receive a frozen `context.config` on the registration, runtime,
  and HTTP handler contexts: the same object identity, per module, beside
  `services` rather than inside it (services is deliberately one shared
  instance). No environment access, no database reference, no way to reach
  another module's values.
- Configuration decides eligibility through ADR 0028's existing resolver:
  three new blocked reasons, each with a distinct operator fix, propagating
  to required dependents through the same mechanism as a disabled module.
- Changes take effect at the next restart; both resources report
  restartRequired. New restricted permission
  `core.modules.configuration.manage`, separate from lifecycle, at the
  sibling prefix `/api/v1/module-configuration`.
- Migration participation stays independent of configuration validity.
  Endpoints and credentials for shared infrastructure stay platform
  concerns; a module declares which bucket it needs, not an S3 endpoint.

See ADR 0029.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 21:44:43 -05:00
The_River fdaad58848 Merge pull request 'feat: add the minimal operational module lifecycle' (#27) from phase-7a/module-lifecycle into main
CI / verify (push) Successful in 35s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 45s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/27
2026-08-23 02:02:13 +00:00
ekklesiaandClaude Opus 5 7729c99e24 feat: add the minimal operational module lifecycle
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 46s
Ekklesia had no notion of a module being on or off: the accepted module set
was exactly "whatever discovery found on disk", and `ModuleLifecycleStage`
named nine states nothing produced or consumed. This replaces that vocabulary
with two axes that real code produces — persistent, administrator-owned
configuration (`enabled`/`disabled`) and per-boot runtime observation
(`running`/`disabled`/`blocked`) — and wires them through startup.

- One Core table (`ekklesia_core_module_state`, migration 0004) holding only
  `module_id` and `enabled`. A module with no record is enabled by default, so
  deploying a module is still sufficient to run it and discovery never needs an
  initialization write. A record for an undeployed module is retained and
  ignored, so redeploying restores the decision.
- `resolveModuleEligibility` is pure: a disabled module transitively blocks
  every module that *requires* it (module and capability edges alike) with a
  structured reason naming the provider; optional dependencies never block; and
  a missing, incompatible, or cyclic dependency stays discovery's concern.
- `startApi` starts only the eligible set and builds the UI publication
  registry from that same list, so routes, artifacts, browser inventory, and
  Dashboard navigation all follow one authoritative set. A disabled module is
  never imported, so there is no per-request enablement check anywhere.
- Startup stays all-or-nothing, deliberately; there is consequently no `failed`
  runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band
  recovery path for a module that breaks startup.
- Changes take effect at the next start, and the read model reports
  configuration, runtime, and `restartRequired` separately rather than claiming
  a module stopped when it has not.
- Migration participation and permission composition still span the deployed
  and discovered inventories respectively, unchanged.
- New restricted platform permission `core.modules.lifecycle.manage` gates both
  reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`)
  that cannot collide with the module route namespace.
- `pnpm doctor` gains a `modules.lifecycle` check so "why is this module
  unavailable?" is answerable without reading logs.

See ADR 0028.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 14:45:34 -05:00
The_River db67cf7700 Merge pull request 'feat: add Dashboard navigation as presentation configuration' (#26) from phase-6d/dashboard-navigation into main
CI / verify (push) Successful in 34s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 43s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/26
2026-08-22 17:56:43 +00:00
ekklesiaandClaude Opus 5 bd34c9c714 feat: add Dashboard navigation as presentation configuration
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 44s
Phase 6D pass 1. The shell's navigation was a flat list of module ids, which
is not a usable experience — and the obvious fix carries an obvious trap.
Letting a module declare its folder, or modelling "Media" as something
Livestream and Video belong to, turns a rendering preference into
architecture: a folder becomes an owner, membership starts to look like a
dependency, and the flat module graph acquires a hierarchy nobody decided on.

So the rule comes first: a Dashboard group is a label on a rendering bucket.
No manifest, no version, no artifact, no API, no lifecycle; nothing depends on
it. Livestream depends on Video because it calls Video's functionality, and
that orders startup. Livestream is shown under Media, and that orders nothing.
No parent module, child module, owning module, or group-derived dependency
appears anywhere in the contracts.

No manifest change and no version 5. `manifest.name` has been required since
version 1 and already is the module's product identity, so the shell renders
"Livestream" instead of `livestream` with nothing added. `suggestedGroup` is
deliberately deferred rather than shipped: it would force a hard
manifest-version cutover for a hint, and its only correct semantics needs
initialization logic that risks a suggestion overwriting an administrator's
decision. Defaulting and ownership are different things.

Two Core tables (migration 0003) hold groups and placements — server-side,
because a church configures its Dashboard rather than one browser;
platform-global, because no organization model exists to scope them to.
`group_id` is `on delete set null`, so deleting a group ungroups its modules
and a dangling reference is impossible. `module_id` carries no foreign key,
because modules are packages rather than rows, so a placement for an
uninstalled module is retained and restores its position on reinstall.

`resolveDashboardNavigation` is a pure function of configuration plus the
mountable set: an unplaced module is ungrouped and visible with no
initialization write, an absent module renders nothing, empty groups are kept,
and ordering is total with the module id as final tie-break.

`GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of
the host-neutral module UI inventory rather than merged into it — each generic
descriptor is embedded untouched, so a standalone host never inherits Dashboard
semantics. Reading needs the permission the inventory already needs; organizing
needs the new platform permission `core.dashboard.navigation.manage` through a
narrow, idempotent, closed-validation mutation contract. No editor UI.

The flatness rule is executable: a test discovers real fixtures with a genuine
dependency, arranges them four ways including both in one folder and the
dependency inverted in presentation, and asserts discovery order, dependency
declarations, and the module set stay byte-identical. The end-to-end test
organizes a real installation through the real API, mounts a module from inside
a group, moves it, and asserts the navigation entry is byte-identical across the
move and still mounts the same artifact.

The shell's client for the generic inventory became dead code and was removed
rather than kept speculatively; the endpoint itself is unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 12:44:40 -05:00
The_River ac5b2b7f6c Merge pull request 'feat: mount discovered module UI in a minimal Ekklesia browser host' (#23) from phase-6c/module-ui-host into main
CI / verify (push) Successful in 33s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 42s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/23
2026-08-22 14:09:32 +00:00
ekklesiaandClaude Opus 5 616c1d4834 feat: mount discovered module UI in a minimal Ekklesia browser host
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 43s
Phase 6C pass 2. Every link in the module UI chain existed except the one
that closes it: a module could be authored, built, validated, published at a
browser URL, and loaded by a generic loader — and nothing in Ekklesia did any
of it. There was no browser application at all.

Two pieces were missing. A browser could not discover what to mount, and it
must not work that out for itself.

GET /api/v1/module-ui is the inventory: per publishable module, its id and
version, the server-produced artifact URL, the server-produced root of the
module's own API, and the subset of that module's declared permissions the
caller holds. Five fields, each because building a ModuleUiHostContext needs
it — no filesystem paths, and no name/icon/folder/order, because navigation
metadata is a design that does not exist yet. Its module set is the ADR 0025
publication registry rather than a rescan, so the inventory and the artifact
route cannot disagree. It requires a new Core permission, core.modules.ui.read
(baseline authenticated), because it is precisely the enumeration the anonymous
artifact route deliberately withholds.

Both URLs are server-produced. moduleUiArtifactUrl already existed;
moduleApiBaseUrl is now exported and moduleRoutePath builds on it, so each URL
shape has exactly one author. Without it the shell would hardcode
/api/v1/modules/<id> — the prefix ADR 0022 forbids a module UI from spelling,
reappearing in the host instead.

surfaces/shell (@ekklesia/shell) is the repository's first surface: a small
React application built to static files by its own esbuild script. It resolves
the principal, fetches the inventory, builds the host context entirely from
server-derived data, and hands the artifact URL to @ekklesia/module-ui-host.
One frame per selection — keyed by module identity — defeats the stale-load
race; the host owns the container, the loading state, and every failure, so a
broken module costs its own panel rather than the shell.

The shell statically imports no module, and both levels are checked: no source
import may name a module, and the emitted bundle must contain exactly one
dynamic import (the loader's) with no module id, no /api/v1/modules, no eval,
and no blob URL. Permission hints come from Core's own evaluator and stay
presentation-only — the end-to-end test has a UI holding no grant call its API
anyway and still receive 403.

Surfaces may now depend only on browser-safe shared roles; @ekklesia/contracts
is deliberately excluded, since its barrel pulls Drizzle's types and Node's
globals into the browser — the hole ADR 0024 closed one layer down.

Not the Dashboard: no navigation, grouping, icons, ordering, design system, or
routing. The selector is a list of module ids.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 08:55:44 -05:00
The_River 7a76ff6f26 Merge pull request 'feat: publish validated module UI artifacts at a platform-owned URL' (#20) from phase-6c/module-ui-asset-publication into main
CI / verify (push) Successful in 31s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 40s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/20
2026-08-22 13:19:25 +00:00
ekklesiaandClaude Opus 5 a02deb9433 feat: publish validated module UI artifacts at a platform-owned URL
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 41s
Phase 6C pass 1. ADR 0024 named the one thing missing between the module UI
build pipeline and the generic host loader: Core resolves entrypoints.ui to an
absolute server filesystem path, and a browser needs a URL. This adds that
publication layer.

Core owns the policy. createModuleUiPublicationRegistry builds, from the
discovered and started module inventory, the answer to "may this be published,
and which exact file is it?" — publishable means the module survived discovery
and declares entrypoints.ui, with no new lifecycle state and no installer.

apps/api owns the transport: GET /api/v1/module-ui/<module-id>/<version>. Both
segments are lookup keys, not path components. An exact match yields the
canonical artifact path discovery already validated; no request value is ever
composed into a filesystem path, and the route declares no wildcard segment, so
traversal, encoded separators, sibling chunks, package-root files, and source
maps are unreachable by construction. An installed module directory never
becomes a static file root.

The prefix is a sibling of /api/v1/modules/, not a child: `ui` is a legal module
route segment, so a module registering GET /ui/:version would otherwise compete
with the platform for one URL with routing order deciding the winner.

The route requires no authentication, deliberately. Fetching the bundle confers
no authority, and a cookie-gated route would break cross-origin import(), which
sends none. The cost is stated rather than hidden: a caller who knows a module
id and its exact version can download that bundle. The three failure modes
return one identical 404 so the route is not an enumeration oracle.

Caching is no-cache with a strong SHA-256 ETag and RFC 9110 If-None-Match
handling, not immutable: without an installer nothing stops a version being
rebuilt with different bytes. The server reads bytes and writes bytes — never
imports, evaluates, or transforms the artifact.

Nothing mounts a module UI. A host could now obtain a URL, which was previously
impossible; there is still no Dashboard shell, navigation, or metadata endpoint.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 08:11:53 -05:00
The_River 82cbeef437 Merge pull request 'fix: require a compiled artifact for entrypoints.jobs' (#19) from fix/compiled-jobs-entrypoint into main
CI / verify (push) Successful in 31s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 42s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/19
2026-08-22 03:11:29 +00:00
ekklesiaandClaude Opus 5 3f9b5e3cb0 fix: require a compiled artifact for entrypoints.jobs
CI / verify (pull_request) Successful in 31s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 42s
Module discovery gated the compiled-ESM rule on an allowlist,
`["schema", "server", "ui"]`, so `entrypoints.jobs` accepted
`./src/jobs/index.ts`. Every other executable entrypoint required a built
`.js` or `.mjs`; `jobs` did not, purely because it was never added to
that list. It is the second entrypoint to slip through the same way —
`ui` did too, until Phase 6A added it explicitly — which is the argument
against the allowlist rather than against `jobs`.

The rule is now expressed as "every entrypoint except a directory
entrypoint", with `migrations` the only member of that set. A new
manifest entrypoint therefore defaults to the stricter validation instead
of silently escaping it. The same constant replaces the two existing
`=== "migrations"` special cases for the file/directory kind check and
its message, so the one thing that distinguishes `migrations` is stated
once. The directory message is now templated and produces byte-identical
text.

Every other entrypoint guarantee is untouched: package-relative paths,
no traversal, inside-package before and after symlink resolution, the
artifact must exist, and it must be a file.

No runtime was added. `entrypoints.jobs` still has no consumer — it is
declared, validated, and never executed — exactly as it has been since
ADR 0006, and as `ui` was before its host loader existed. The manifest
schema is unchanged and this stays within v4: the field, its type, and
its optionality are all the same, and only discovery's validation of the
declared path tightened.

Compatibility impact is nil in first-party code: no module, template, or
fixture declares a `jobs` entrypoint at all. A third-party module
declaring a source-TypeScript path would now fail discovery, which is the
intended correction.

Tests cover accepted `.js` and `.mjs`, rejected `.ts`/`.tsx`/`.cjs`, a
missing artifact, a directory, a symlink escaping the package, and that
discovery still never evaluates the artifact. The three rejection cases
were confirmed to fail against the previous implementation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 22:07:54 -05:00
The_River ff159a04dc Merge pull request 'feat: close the browser boundary and add the generic UI host loader' (#18) from phase-6b/module-ui-host into main
CI / verify (push) Successful in 55s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 41s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/18
2026-08-22 03:00:56 +00:00
ekklesiaandClaude Opus 5 44e676f0df feat: close the browser boundary and add the generic UI host loader
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 41s
Two gaps stood between the module UI contract and a host that could use
it. ADR 0023 recorded the first honestly but never diagnosed it: a module
UI file compiled with `"types": []` could still typecheck `process.env`.
The second was simply missing — nothing imported a compiled artifact,
checked its identity, mounted it, or tore it down.

The leak was one type-only import, bisected against the real type graph
rather than guessed. `@ekklesia/module-ui-sdk` imported
`type { EkklesiaPrincipal } from "@ekklesia/contracts"`. Importing
contracts/identity alone is clean and importing Zod alone is clean, but
contracts exposes a single barrel entry, so that one import reached
module-database, whose Drizzle types re-introduce Node's globals. The
runtime was never affected — the import was erased and host-context.js is
`export {}` — but a type-graph leak is still a real boundary failure,
because it silently permits code that cannot run in its target
environment.

The fix is not a narrower import path. The SDK now has no dependencies at
all, so there is no graph to leak through and no future contracts change
can reopen this. It owns `ModuleUiPrincipal`: the same shape ADR 0022
chose, with the Zod brand dropped. That brand encodes a server-side
parsing guarantee a browser cannot produce, and it is the same reason
`permissions.has` already took a plain string. An Ekklesia host still
passes its own `EkklesiaPrincipal` straight through — a branded string is
assignable to `string` — while a standalone host builds the literal.
Proven the way an author experiences it: two fixtures compiled under the
exact project a module uses, one supported and one deliberately reaching
for `process`, with the second required to fail on that specific name.

`@ekklesia/module-ui-host` is the generic loader, in its own package
because the SDK is bundled into every module artifact and host-only
loader code has no business shipping there. The boundary checker enforces
both directions: modules may not depend on the loader, and the loader may
depend only on the SDK plus dev-time tooling.

The distinction that shaped the design: Core resolves `entrypoints.ui` to
an absolute path on the server's disk, and a browser needs a URL. A
loader that accepted the path would test green on a server and fail in
every real host, and would quietly own asset publication. So the loader
takes a browser-loadable URL and knows nothing about how one is obtained.
Ekklesia has no route that publishes installed artifacts, so this is
precisely: loader complete, publication deferred. Nothing can mount an
installed module UI yet, and the docs say so rather than implying
otherwise.

Dynamic import is `(url) => import(url)` with a non-analyzable specifier,
kept as a runtime import so a host does not statically bundle every
installed module or need a hardcoded registry. It is injectable for
bundlers needing their own escape hatch, and deliberately not `eval` or
`new Function`, which would cost a host `unsafe-eval` in its CSP.

Identity is validated through the SDK's existing parser before `mount`
runs, so a stale or mismatched bundle never renders. Contract violations
propagate unwrapped; the loader adds only import and mount failure codes,
sanitized, with the cause attached. Unmount is idempotent, safe when a
module returned no cleanup, and marks an instance unmounted before
running cleanup so a throwing cleanup surfaces once and never re-runs. A
failed mount registers no cleanup. Each instance owns its own state, so
two mounts coexist. The loader never mutates the host's container — not
on success, failure, or unmount.

Tests prove all of it against real module URLs (`data:`, which both
browsers and Node import natively) rather than filesystem paths,
including one artifact built by the canonical Pass 2 pipeline and run
under both an Ekklesia-shaped and a standalone-shaped context. Core,
apps/api, and the contracts package are untouched; discovery remains
inert.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 21:44:42 -05:00
The_River 5d75d910fd Merge pull request 'feat: build module UI into its declared browser artifact' (#17) from phase-6b/module-ui-build into main
CI / verify (push) Successful in 40s
CI / infrastructure-smoke (push) Successful in 22s
CI / migration-smoke (push) Successful in 47s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/17
2026-08-22 02:28:59 +00:00
ekklesiaandClaude Opus 5 1e5866504e feat: build module UI into its declared browser artifact
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 26s
CI / migration-smoke (pull_request) Successful in 50s
ADR 0022 defined what a module UI is and selected React and TypeScript
as the authoring model, but closed by recording that it was not buildable
end to end: `browser.json` preserves JSX, bundler plugins are denied, and
nothing turned a module's `.tsx` into the artifact its manifest declares.
This closes exactly that gap.

esbuild produces the artifact. It was already the right answer before it
was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest
today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`,
so the pinned dependency adds no second copy and no new trust decision.
It is also the smallest tool that covers TSX, ESM, browser platform,
bundling, source maps, and a define-time constant with no plugin
ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which
the module boundary denies on purpose; `tsc` alone cannot bundle, so its
output would carry bare `react/jsx-runtime` imports no browser resolves.

React is bundled, not externalized. That follows from ADR 0022 rather
than from convenience: `mount()` was made the ABI precisely so React
would not be part of the host contract, and externalizing it would
reverse that through the build system — every host would have to supply
a compatible React, and a standalone host might not use React at all.
The cost is real and accepted: duplicate runtimes and a ~570 KB
unminified artifact. Sharing one runtime later is a build optimization
that cannot change the contract, because the contract is a mount
function.

Output is browser ESM at `es2022`, unminified so an operator can review
what they install, with `process.env.NODE_ENV` substituted — React reads
it at module scope and would otherwise touch a Node global that does not
exist in a browser. The output path is read from the module's own
manifest rather than a build convention, so declared and produced
artifacts cannot drift, and it is validated with the same
`packageRelativePathSchema` discovery enforces, so a build can never
write where discovery would later reject.

`@ekklesia/module-ui-build` owns every option; modules own none. It uses
the existing `tooling` role that modules could already depend on, so this
pass required no boundary relaxation at all. Its `bin` points at a
committed launcher rather than compiled output because pnpm links bins
during install, before Turborepo builds anything — a bin targeting
`dist/cli.js` fails to link on a fresh clone and breaks every module's UI
build in CI. A module with no `entrypoints.ui` is not an error; the CLI
reports nothing to build and exits zero, so one script serves both kinds
of module.

Styling is React inline style objects. A stylesheet import fails the
build rather than emitting a sidecar `.css` that no manifest declares and
no host loads — an undeclared asset would appear to work in tests and
silently do nothing in a real host.

`templates/module` is the canonical example and now ships optional UI.
Its tests build the artifact from source, validate it with
`parseModuleUiExport`, mount it into a real DOM, assert the rendered
output, and unmount it; the artifact is rebuilt per run so the proof can
never pass against stale output, and `dist/` stays gitignored. Because
verify runs both `pnpm test` and `pnpm build`, a UI that cannot be
produced fails CI through two independent paths.

One correction worth stating plainly: the UI TypeScript project declares
`"types": []`, and a UI file referencing `process` does fail in
isolation, but TypeScript still pulls Node globals in transitively — a
file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts`
and through its Zod and Drizzle types `process.env` typechecks again.
The docs and ADR describe this as the convention it is rather than the
guard it is not.

Core is untouched. Building is an authoring step; discovery still only
validates the artifact a module shipped and never executes or produces
one.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 21:24:18 -05:00
The_River f9c1108527 Merge pull request 'feat: define the module UI authoring model and host contract' (#16) from phase-6b/module-ui-contract into main
CI / verify (push) Successful in 36s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 43s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/16
2026-08-22 02:02:07 +00:00
ekklesiaandClaude Opus 5 8d651c427d feat: define the module UI authoring model and host contract
CI / verify (pull_request) Successful in 30s
CI / infrastructure-smoke (pull_request) Successful in 23s
CI / migration-smoke (pull_request) Successful in 45s
ADR 0021 established that a module may own UI and added a statically
validated, never-executed `entrypoints.ui` path, but deliberately left
five questions open: what the artifact exports, what a host provides,
how one artifact runs under both Ekklesia and a standalone host, what
the browser-side trust model is, and how module UI avoids being welded
to a Dashboard that does not exist. This answers them.

React and TypeScript are the supported module UI authoring model. The
repository already leaned this way: `packages/typescript-config` ships a
`browser.json` preset and a `react-native.json` preset with
`"jsx": "react-jsx"`, and ADR 0002 names browser and React Native
configurations. One framework is supported; every other stays rejected.

React is deliberately not part of the contract. A module's compiled
artifact default-exports `defineModuleUi({ id, version, mount })`, and
`mount(container, context)` takes a DOM element and returns an unmount
function. `id` and `version` are build-artifact identity markers a host
verifies against the manifest, mirroring `defineModuleRuntime` exactly.
A host needs an element and a context, never a matching rendering
library, so a module's framework, a host's framework, and the platform
boundary between them stay independent.

The contract lives in a new `@ekklesia/module-ui-sdk` rather than in
`@ekklesia/module-sdk`, for a concrete reason: that package re-exports
all of `@ekklesia/contracts`, so routing module UI through it would drag
Zod, Drizzle, and semver into every browser bundle. The new package has
no runtime dependencies, and its one dependency on contracts is
type-only — the emitted `host-context.js` is `export {};`, and a test
pins that so it cannot regress.

The host context is four members: `moduleId`, `api`, `principal`, and
`permissions`. Navigation, theme, module configuration, organization
context, and diagnostics are deliberately absent; each needs a design
that does not exist. Module UI reaches its backend through
`api.request(path)` with a path relative to its own API root and never
constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private
deployment detail — a module that builds that URL is silently unhostable
anywhere else. One fixture artifact is mounted under two hosts with
different origins and URL shapes to prove it.

Client-side permission hints are presentation only, and both the docs
and a test say so plainly: a denied hint does not stop the module from
calling its API, because server-side `requirePermission` is the only
authority. Module UI is trusted code in the host's own browser realm —
no iframe, no sandbox — which matches the backend model rather than
overstating it, since a module's server half already holds the API's
database authority.

The boundary guard is narrowed precisely, not removed: `.tsx`, `react`,
and `react-dom` are un-denied and modules may depend on the new
`module-ui-sdk` role. `.jsx`, `.css`, every other framework,
`react-router-dom`, `react-native`, and `@vitejs/plugin-react` stay
denied, layer rules are untouched, and an unsupported framework aliased
behind an allowed name (`"react": "npm:vue@3"`) is resolved and rejected
by its real target. Fixtures prove each case, including that module UI
source still cannot import Core.

Core, apps/api, and the manifest contract are untouched. Discovery still
validates the `entrypoints.ui` path and never evaluates it; hosts
perform their own dynamic import and validate the result with
`parseModuleUiExport`. No Dashboard shell, UI runtime, navigation,
grouping, standalone host, build or styling pipeline, installer,
hot-loading, or feature module is added.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 20:52:47 -05:00
The_River b5aa4371b6 Merge pull request 'feat: establish the module product contract' (#15) from phase-6a/module-product-contract into main
CI / verify (push) Successful in 29s
CI / infrastructure-smoke (push) Successful in 22s
CI / migration-smoke (push) Successful in 43s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/15
2026-08-22 01:32:48 +00:00
ekklesiaandClaude Opus 5 44ba259f58 feat: establish the module product contract
CI / verify (pull_request) Successful in 2m31s
CI / infrastructure-smoke (pull_request) Successful in 15s
CI / migration-smoke (pull_request) Successful in 49s
Ekklesia's module model has been backend-only since ADR 0001: a module
owns domain behavior and data, and every presentation concern belongs to
a surface. That prevents a module author — the person who knows the
domain — from shipping the screen that makes their module useful, and it
guarantees a module and its own UI drift apart. It also pushes toward two
codebases for one domain the moment a module needs to run standalone.

ADR 0021 replaces that rule. A module is an independently versioned
product that owns its domain behavior, data, permissions, routes,
migrations, and its own UI, hostable inside Ekklesia and — from the same
implementation — by a later standalone host. Core keeps the containing
application shell and every platform policy: authentication,
authorization enforcement, platform context, top-level routing,
navigation, global theme, error boundaries, and trusted-origin/CORS. The
module graph stays flat: modules are peers, genuine module and capability
dependencies remain the only relationships, and Dashboard folders are
presentation configuration an administrator can reorganize, never module
architecture.

This pass implements the static contract only. `manifestVersion` moves to
4 and `entrypoints` gains one optional field, `ui`. Version 3's
`entrypoints` is strict and rejects `ui`, so this is an incompatible
shape change and version 3 is now rejected outright rather than
reinterpreted, exactly as version 1 was by version 2 and version 2 by
version 3. Every field version 4 adds is optional, so upgrading a
backend-only manifest is a one-character edit.

Discovery resolves and validates `ui` under the same rules as `server`
and `schema` — package-relative, inside the package before and after
symlink resolution, a real file, compiled ESM `.js`/`.mjs`, so source
TypeScript is rejected rather than assumed to be compiled later.
Discovery stays inert: nothing imports, evaluates, mounts, or renders a
UI entrypoint, and a test proves a throwing artifact still discovers
successfully. This follows `entrypoints.jobs`, declared and resolved
since ADR 0006 with no runtime consumer.

The name is `ui`, not `dashboard`, because a module's UI must be hostable
by Ekklesia's shell and by a standalone host, so the contract cannot be
named after one of them; and not `surface`, because `surfaces/*` is a
separate ownership layer. No widget, route, icon, folder, menu, layout,
or standalone-metadata field is added, and tests assert the manifest
still rejects every unknown entrypoint key and offers no hierarchy field.

Deliberately unchanged: `scripts/check-boundaries.ts` still enforces
`modules-do-not-own-ui`. Relaxing that guard depends entirely on the UI
authoring model a later pass chooses, and weakening a working protection
before that decision exists would trade real coverage for nothing. A
module may therefore declare `ui` today only for a framework-neutral
compiled artifact, and the docs say so rather than implying otherwise.
Restart-based discovery, explicit operator-invoked migrations, and the
trusted compiled-module model are untouched — a module that ships UI is
exactly as trusted as one that does not.

No Dashboard shell, UI runtime, renderer, framework choice, navigation
registry, grouping system, standalone host, installer, hot-loading, or
feature module is added by this pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 20:20:41 -05:00
The_River 94d26cffa0 Merge pull request 'phase-5/security-hardening' (#14) from phase-5/security-hardening into main
CI / verify (push) Successful in 27s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 42s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/14
2026-07-30 03:04:36 +00:00
ekklesiaandClaude Opus 5 288c088377 docs: close Phase 5D security hardening
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 42s
The documentation still described Phase 5D as in progress, Phase 6 as
blocked, and a disposable-runner isolation proof as a prerequisite for
opening the real pull request. All five passes and the independent
whole-repository re-audit have landed, so those statements no longer
match the accepted operating model.

The README's current status now records Phase 5D as complete under the
current trusted-contributor, not-yet-deployed threat model, with Phase 6
unblocked, and gains a pass 5 closure paragraph covering the re-audit,
the repository-local supply-chain and dependency fixes, the accepted
trusted-code-only CI boundary, and what remains required before
untrusted CI or a public launch. The pass 1-4 paragraphs keep their
technical content unchanged; only their tense changes, so each reads as
what was outstanding at the close of that pass rather than as a current
claim.

`platform-architecture.md` no longer stops at pass 1 or says passes 2-5
are unfinished; it summarizes all five passes and defers the remaining
operational limitations to the security document.

`security.md` states the trusted-code-only operating rule explicitly and
replaces "CI blockers before the real pull request is opened" with
"Requirements before executing untrusted contributions", framing the five
hardening items as prerequisites for relaxing that rule rather than as
Phase 6 blockers, and naming an isolated hosted-runner service as an
accepted alternative. The Gitea 1.24.2 warnings, the scope of
`persist-credentials: false`, and the unverified runner topology and
job-token authority are preserved verbatim; no external Gitea setting is
claimed. The launch checklist is marked as gating a public launch, not
development.

Both Doctor examples are corrected to the real three-migration output,
adding `core:0002_authorization_integrity · 634294731180`. The stale
sentence calling Core authorization the next architectural phase is
refreshed.

ADRs 0017-0020 are untouched: their statements that later passes were
outstanding are historical records made when each was accepted.

Documentation only — no production code, test, workflow, dependency,
migration, or schema change.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 21:59:26 -05:00
ekklesiaandClaude Opus 5 27088fc054 fix: run the TLS integration harness under a containerized job
CI / verify (pull_request) Successful in 28s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 41s
The `migration-smoke` job fails on the Gitea runner at
`docker port ekklesia-database-tls-<pid> 5432` with
`no public port '5432' published`; the later invalid-URL and
`ECONNREFUSED 127.0.0.1:5432` failures all cascade from it.

Gitea executes the job inside a container and drives sibling containers
through the runner's Docker daemon. The harness held two host-only
assumptions, both reproduced in a nested job container and fixed here.

Bind-mount source paths are resolved by the daemon against the runner
host's filesystem. The certificate directory is created in the job
container's `/tmp`, which does not exist there, so the daemon silently
creates an empty directory, the entrypoint's `cp` fails, the container
exits, and `docker port` reports no published port for an exited
container. The material is now transferred with `docker cp` into the
created-but-not-yet-started container, which crosses the daemon boundary
correctly in both topologies, and only the server leaf and its key are
transferred — both CA private keys stay in the harness directory.

A published port lands on the runner host's loopback, a different
network namespace from the job container's; measured, a container the
daemon published at `127.0.0.1:32907` is `ECONNREFUSED` at that address
from inside the job container. Under a containerized job the harness now
publishes nothing and joins both containers to one isolated user-defined
network, reaching PostgreSQL by container DNS on 5432. Direct-host runs
keep a random port pinned to loopback via Docker's `127.0.0.1::5432`
form. `compose_job_network_job_container` exports the topology decision
the integration script already made, so there is still exactly one
detector.

Every TLS assertion is preserved, and the certificate identity is
generated from the host actually connected to — `localhost` directly, the
container's DNS name otherwise. The mismatch and chain-only controls use
the TLS container's address on the isolated network rather than a
hardcoded one, and the certificate carries no IP SAN, so verify-full
still fails with `ERR_TLS_CERT_ALTNAME_INVALID`.

Correcting one measurement in passing: chain-only verification does not
skip the identity check. For an IP host `pg` omits `servername` and hands
`tls.connect` a bare socket with no host, and `net.Socket._host` is null
for an IP literal, so Node checks the fixed fallback name `localhost` —
never the address dialled. A certificate whose only SAN is
`DNS:example.invalid`, reached chain-only over `127.0.0.1`, is rejected
with `Host: localhost. is not in the cert's altnames`. The direct-host
control therefore only ever passed because its SAN was `DNS:localhost`.
The SAN set now always includes that fallback name so the control proves
the same gap in both topologies, and the stale claim is corrected in the
transport doc comment. No production behavior changes.

Teardown also removes the container's anonymous volume, the isolated
network, and the job container's attachment, after success and failure
alike.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 21:42:26 -05:00
ekklesiaandClaude Opus 5 a2db774b8c docs: correct Gitea Actions guarantees
CI / verify (pull_request) Successful in 29s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Failing after 39s
The security document claimed the CI workflow "is least privilege" because it
declares `permissions: contents: read`. That is not true on the server this
repository actually uses.

The inventoried server is Gitea 1.24.2, and its official comparison page lists
`permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
`jobs.<job_id>.timeout-minutes` under unsupported workflow syntax — verified
against https://docs.gitea.com/1.24/usage/actions/comparison rather than assumed.
On this server those keys are inert, so the branch cannot claim the in-job
GITEA_TOKEN is read-only, and cannot claim YAML-enforced timeout or concurrency
behavior. Job-token permission controls, repository/owner defaults, and
server-side maximum-permission clamping arrive in the 1.26 feature line
(https://docs.gitea.com/1.26/usage/actions/token-permissions).

The document now separates what is actually true of this repository — full-SHA
action pins, frozen-lockfile install, no pull_request_target, no referenced
secret, `persist-credentials: false` on every checkout, and the declared
`permissions` block — from what the server enforces. It also states precisely
what `persist-credentials: false` proves: checkout leaves no credential in
`.git/config` for a later step, which is not the same as removing or reducing
the job token. The CI blockers are recorded: upgrade to a reviewed supported
release, configure repository/owner defaults and a maximum permission, prove
against the real server what the token cannot do, move timeout/cancellation/
teardown into the trusted provisioner and reaper, and finish the disposable-guest
proof before opening the real pull request.

The workflow keeps every declaration — they are already correct for a supported
Gitea release and for GitHub Actions — and gains a comment saying plainly that
this server ignores them, so no future reader mistakes them for enforcement.

Nothing about runners, proxies, firewalls, or deployment is asserted here.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 20:10:20 -05:00
ekklesiaandClaude Opus 5 cda841f8c7 test: gate the graceful drain proof on handler entry
The Pass 4 drain test waited for `server.getConnections() > 0` before starting
shutdown, and a comment described that as the request having "demonstrably
entered". It had not: the connection count proves the server accepted a TCP
connection, nothing about whether Hono had dispatched to a handler. Run five
times against the real integration harness it was not flaky, but the claim was
stronger than the evidence, so the evidence is now stronger instead.

A fixture module's route handler announces entry through a `globalThis` gate —
the same channel the existing lifecycle recorders use — and then blocks until the
test releases it. Shutdown therefore begins at a point where the handler is
provably inside user code. The assertions are now about recorded ordering rather
than timing: the handler finished, the module stopped, and `handler-finished`
precedes `stop` in the recorded events, with the full 200 response and a clean
transport close. No sleeps; the gate is released in `finally` so a failed
assertion cannot hang the suite.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 19:53:25 -05:00
ekklesiaandClaude Opus 5 07a3c61083 docs: define deployment trust boundaries
One canonical security document, linked from the README, platform architecture,
and module-development guide, stating what the boundaries actually protect
against — and, more importantly, what they do not.

Installed modules are trusted compiled backend code. A module runs arbitrary
JavaScript inside the API process and shares its PostgreSQL role and pool, so
`ModuleDatabase.execute` gives it the API's full database authority over every
table, including identity, grants, owner, bootstrap, and audit. Table prefixes,
manifests, permission declarations, route validation, frozen contexts, typed
facades, and the capability registry are correctness and coordination boundaries
that assume a cooperative module; none of them constrains one that is not. There
is no process, container, syscall, filesystem, network, database-role, schema,
tenant, or resource sandbox between a module and Core, and the document says so
in those words, along with what would have to be built before untrusted modules
could be accepted.

It also records the honest deployment state: Ekklesia is not deployed. There is
no production instance, reverse proxy, public listener, or deployment automation
anywhere in this repository, and no `.env` has ever been committed. The loopback
listen default and the production signup refusal are real, but a config default
is not a firewall, so the origin-reachability, proxy-overwrite, header-spoofing,
and trusted-peer properties a public launch needs are written as a
launch-blocking checklist rather than as guarantees. The per-process
authentication limiter is described as exactly that, valid only while signup
stays disabled and exactly one instance serves traffic.

The CI section records the workflow's verified posture and deliberately stops
there: a pull request can edit `runs-on`, so a workflow file cannot be the
boundary, and the runner topology is not described as though it had been
verified.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 19:49:12 -05:00
ekklesiaandClaude Opus 5 f7621a599f fix: remediate dependency advisories
Three advisory paths, closed with the narrowest scoped overrides rather than a
broad upgrade. Each raises exactly the one edge that resolved a vulnerable
version, and each pins an exact release.

PostCSS 8.5.17 (GHSA-r28c-9q8g-f849, high) was reached only through Vite, which
better-auth pulls in via Vitest, and Vite 8.1.4's own range still resolved the
vulnerable version. `vite>postcss` now resolves 8.5.24; one version in the tree.

esbuild 0.18.20 (GHSA-67mh-4wv8-2f99, moderate) was reached only through the
deprecated @esbuild-kit/core-utils, whose pinned range predates the fix. Scoped
to `@esbuild-kit/core-utils>esbuild` on purpose: drizzle-kit already resolved
esbuild 0.25.12 directly and tsx/vite resolve 0.28.1, so a global override would
have dragged those newer, unaffected versions backwards. The vulnerable version
is gone and 0.28.1 is preserved.

Lodash 4.17.21 (GHSA-r5fr-rjxr-66jc high, GHSA-f23m-r3pf-42rh and
GHSA-xxjr-mmjv-4gpg moderate) was reached only through the development-time
Better Auth `auth` CLI's Prisma parser. All three chevrotain packages pin lodash
to exactly 4.17.21, so no in-range release is patched, and the advisories
require >= 4.17.24 — a version the 4.17.x line never published. 4.18.1 is the
lowest fixed release; the override crosses chevrotain's exact pin for that
reason. Verified compatible before applying: 4.18.1 keeps the per-method
CommonJS subpath files chevrotain requires (`lodash/assign`, `lodash/isRegExp`,
and the rest), with the same `main` and no restrictive `exports` field.

Both `pnpm audit --prod` and the full `pnpm audit` now report no known
vulnerabilities, with no ignore, allowlist, or audit-level suppression.

Proven, not assumed: `pnpm install --frozen-lockfile` succeeds; the Better Auth
schema generator still matches the checked-in identity schema, which exercises
the very chevrotain/lodash path that was overridden; drizzle-kit loads the real
config and reports no schema changes against a scratch copy, leaving tracked
migration artifacts untouched; the three Core migrations keep their exact
checksums; and the whole unit and PostgreSQL integration suites stay green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 19:49:12 -05:00
ekklesiaandClaude Opus 5 51f4075fd6 ci: stop persisting the job token
`actions/checkout` defaults to `persist-credentials: true`, which writes the job
token into `.git/config` and leaves it there for every later step. Nothing in
this workflow pushes, so the credential has no use after checkout — but a pull
request can edit this file, and any step it adds runs in the same workspace.

All three jobs now check out with `persist-credentials: false`. The rest of the
workflow's posture is unchanged and was re-verified: top-level
`permissions: contents: read`, both actions pinned by full commit SHA,
`pnpm install --frozen-lockfile`, no `pull_request_target`, and no secrets
referenced anywhere.

This is the one property from the Pass 5 runner-isolation requirements that is
repository-local and statically verifiable. The rest — whether pull-request jobs
run on a disposable guest, what Docker daemon they reach, and which runners this
repository can select — depends on the actual Gitea and runner hosts and is not
claimed here.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 19:49:12 -05:00
ekklesiaandClaude Opus 5 6912680113 fix: prevent migration timeout overrides
pg-connection-string retains arbitrary query keys, so the driver's own
configuration key connectionTimeoutMillis reaches ConnectionParameters straight
from a URL — and it was not on the rejected list, while spellings that do
nothing were. Rejecting it closes that gap.

The precedence claims that guard was built on were partly wrong, so every source
was measured against the installed [email protected].0, a real blackhole listener, and a
real PostgreSQL rather than inferred from the merge:

  statement_timeout=0 / =900000   server reported 0 and 15min — wins outright
  query_timeout=0                 overrides the field, but the parsed value is
                                  the string "0", which is truthy, so every
                                  query gets a 0 ms read timer and fails
                                  nondeterministically — it breaks the client
                                  rather than loosening the bound
  connectionTimeoutMillis=0/900000 pollutes connectionParameters.connect_timeout
                                  (10 -> 0 or 900 s), but that field only feeds
                                  getLibpqConnectionString(), which the pure-JS
                                  client never calls; the effective timer was
                                  unchanged, with an explicit 500 ms still
                                  expiring at ~500 ms either way
  connect_timeout=0               no effect: recomputed from the explicit field
  options=-c statement_timeout=…  no effect, including a backslash-escaped
                                  spelling — the named startup parameter wins
  PGOPTIONS / PGCONNECT_TIMEOUT   no effect
  unrelated parameters            no effect, deliberately untouched

Only statement_timeout is a genuine downgrade. The rest are rejected as
unsupported configuration and defense in depth: no legitimate DATABASE_URL
carries a driver option, the polluted connect_timeout would take effect on the
native binding, and query_timeout produces intermittent failures an operator
could not diagnose. That distinction is now stated where it was previously
asserted wrongly — the earlier comment and ADR text claimed connect_timeout
overrode the explicit field, which measurement disproved.

Rejection still happens at the one shared construction boundary, before any
pg.Client exists, with the existing fixed POSTGRES_MIGRATION_CONFIGURATION_INVALID
and no URL, host, port, credential, parameter, or value in the message. The
10 s defaults, narrower test overrides, Pass 2 TLS policy and its URL-conflict
checks, and every unrelated parameter are unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 19:01:46 -05:00
ekklesiaandClaude Opus 5 74e8840f0d test: prove bounded API cleanup
Pass 4 bounded module hooks and the HTTP drain, and Core proves an individual
hook deadline in isolation — but nothing under apps/api ever supplied
moduleHookTimeoutMsOverride, so the composition's actual promise ("the pool
always closes, whatever a hook does") was untested at the level where it either
holds or breaks.

Four deterministic proofs, no production change:

A never-settling startup rollback now proves startApi rejects with
MODULE_START_FAILED followed by MODULE_ROLLBACK_TIMEOUT, that the failing
module's own stop hook was attempted first and the earlier module still received
its rollback afterwards, that the one pool closed exactly once, and that no
listener was ever bound.

A never-settling normal stop proves shutdown rejects with MODULE_STOP_TIMEOUT
metadata, the earlier module still got its stop attempt, the pool closed, and
repeated and concurrent callers share the one memoized result without
re-entering a single hook.

A combined failure proves both deadlines report together: a server whose close
callback never fires plus a never-settling stop yields drainTimedOut and
moduleStopFailed on one ApiShutdownError, the platform force-closed through
Node's public closeIdleConnections/closeAllConnections, module stop still ran,
the pool still closed, and nothing about the request, socket, connection,
credentials, SQL, or module exception appears in the error.

The graceful ordering test was strengthened rather than left racing an
unresolved fetch: a raw socket sends a complete request with Connection: close,
shutdown begins only once the server provably holds that connection — observed
by polling the server's own connection count, never by sleeping — and the
already-entered request is then proven to complete with a full 200 response and
a clean transport close instead of being force-closed.

The fixture gains one test-only option for a stop hook that never settles.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 19:00:39 -05:00
ekklesiaandClaude Opus 5 2983789779 fix: harden migration command execution
Two problems in the migration commands: no timeouts, and SQL split on literal
bytes.

`migrations:plan` and `migrations:apply` constructed their connection with
neither a connection deadline nor an operation deadline — a real server reported
`statement_timeout = 0` on the migration connection. Both now default to 10
seconds for the connection deadline, the client query timeout, and PostgreSQL's
own statement_timeout, applied at the one shared construction boundary so a
future call site cannot silently omit them. History reads, advisory-lock
queries, transaction control, migration SQL, and history writes are all covered.
The existing lock-wait deadline, the Pass 2 TLS policy, and Doctor's and the
bootstrap CLI's own timeouts are untouched, and no environment variable or flag
was added.

Explicit client fields do not win under the pinned driver. [email protected].0 builds
parameters as `Object.assign({}, config, parse(config.connectionString))`, so
the URL overrides passed options — verified directly, where an explicit
statement_timeout of 10000 alongside `?statement_timeout=0` produced a
server-reported 0, while a clean URL produced 10s. A DATABASE_URL carrying
connect_timeout, query_timeout, statement_timeout, or an `options` value setting
statement_timeout is therefore refused with the existing fixed configuration
error, before any client is constructed and without echoing the URL, parameter,
or value. Unrelated parameters are left alone.

The runner split each migration on the literal `--> statement-breakpoint` bytes.
Those bytes inside a string literal, a dollar-quoted function body, a quoted
identifier, or a comment tore the migration into fragments that were no longer
valid SQL — each case verified to produce two fragments where one was required.
Each migration's exact SQL is now passed to the client once, with no values and
no prepared-statement name, inside the existing transaction, and its history row
inserted only after that batch succeeds. One transaction still spans all pending
migrations; advisory locking, checksum validation, approval preflight, ordering,
rollback, cleanup, and sanitized error identity are unchanged. The port method
is renamed executeStatement to executeMigrationSql so the contract says what it
receives.

PostgreSQL parses the batch because it owns the only correct parser. A homegrown
splitter would have to track single quotes, dollar quoting with arbitrary tags,
quoted identifiers, line comments, nested block comments, and escape syntax to
answer a question the server already answers, and a parser dependency would add
supply-chain surface for the same reason. Drizzle's marker lines are ordinary
`--` comments when the batch is sent whole. No custom SQL parser exists here.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 23:13:52 -05:00
ekklesiaandClaude Opus 5 490b85b07c fix: bound module lifecycle shutdown
Nothing bounded shutdown, and a module that failed to start was never cleaned up.

`startModules` appended to its rollback set only after `start()` resolved, so a
hook that acquired a connection, registered a listener, or recorded partial
initialization and then threw had its own `stop()` skipped, while every
previously completed module was rolled back correctly. A module now joins the
rollback set the moment its start hook is invoked: its own stop hook runs first,
then earlier modules in reverse order. The original start issue stays first in
the reported issues, and a failure stopping the failing module is appended
without preventing the rest of the rollback. The consequence for module authors
is explicit and documented — `stop()` may run after a partially completed
`start()` and must be safe and idempotent about resources it may or may not
hold.

Neither stage of shutdown had a deadline. `shutdown()` awaited `server.close()`
with no bound, so one open connection could hold it forever, and each stop or
rollback hook was awaited without a bound, so a hook that never settled kept the
pool open indefinitely. Both now have fixed, non-configurable defaults: 10
seconds per module hook and 30 seconds for the graceful HTTP drain. A hook that
reaches its deadline records MODULE_STOP_TIMEOUT or MODULE_ROLLBACK_TIMEOUT and
the sequence continues with the next module, so the whole stop sequence is
finite in the started module count rather than one global ten-second bound.
Timers that lose the race are cleared, the losing promise gets a permanent
rejection handler so a late throw cannot become an unhandled rejection, and a
timed-out hook is never awaited again.

At the drain deadline the platform closes idle and then active connections
through Node's own public closeIdleConnections/closeAllConnections, after
server.close(), touching no private field, and stops waiting for the close
callback. It then runs the bounded module stop sequence and closes the pool, so
a drain timeout or a stop failure never skips later cleanup. Both are reported
together as an ApiShutdownError carrying only fixed booleans and the already
sanitized module error; a forced drain is a shutdown failure, not a success.

Stated honestly rather than overstated: a deadline bounds how long the platform
waits, not how long module code runs. An in-process promise cannot be canceled,
and closing a socket does not prove a handler promise ended. What is guaranteed
is that shutdown stops waiting and the pool closes afterwards, so late database
work fails.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 23:12:18 -05:00
ekklesiaandClaude Opus 5 800ecfdb92 fix: reject ambiguous module routes
A module can register both `GET /events/:id` and `GET /events/new`. Driven
through the pinned router, `/events/new` reached the parameter handler under
`demo.events.read` when the parameter route registered first, and the static
handler under `demo.events.create` when it registered second. Nothing rejected
the pair, so declaration order decided which permission guarded the path. That
is a permission policy, not a routing preference, and it must not be inferred
from array position.

Two routes of one module now overlap when they share a method and a segment
count and, at every position, either both segments are the same static literal
or at least one is a parameter. Under this grammar — static literals or `:name`
parameters, no wildcard, regex, optional, or catch-all — that is exactly "some
concrete request path matches both". `moduleRoutesOverlap` implements it in the
contracts package, where path semantics already live and Hono cannot reach.

Core checks it during staged registration, after the existing duplicate check,
so identical shapes (including paths differing only in parameter name) keep
returning MODULE_ROUTE_DUPLICATE and a genuine intersection returns the new
MODULE_ROUTE_OVERLAP. The rejected module publishes none of its staged routes or
capabilities, no start hook runs, and no listener binds. Rejection rather than a
specificity rule: ranking static above parameter would still silently pick which
permission applies. Disjoint routes keep their order; different methods,
different segment counts, and the same local path in two different modules
(each has its own platform-owned mount prefix) all remain legal.

The same requests also pinned down what handlers actually receive. Route
declarations are validated; route *parameters* are not, and the two are
unrelated. A single encoded segment arrives decoded as `/../admin`, `a\b`, an
embedded NUL, CR/LF, `1; drop`, or non-ASCII text — none of which any declared
path could contain. The contract now says so explicitly: decoded values are
untrusted, module-owned input that a handler must validate against its own exact
domain grammar before any database, filesystem, command, or identifier use, and
re-encoding is not a substitute. No generic validator, path-normalization layer,
or route field was added, because module parameters are module-owned domain
types and one platform-wide pattern would either reject legitimate values or
approve meaningless ones.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 23:10:51 -05:00
ekklesiaandClaude Opus 5 4a34a8203c fix: revalidate grant revocation under lock
`revokeGrant` discovers the addressed grant's equivalence tuple before taking
that tuple's advisory lock, which is safe because those columns are immutable.
The all-equivalent update that follows then matches on the tuple rather than on
the addressed id — and nothing re-checked the addressed grant once the lock was
finally held.

That allowed a revocation to succeed by revoking a grant it never addressed. A
revoker reads its target and stalls before the lock; a second revoker takes the
lock, revokes that same target, audits it, commits; a creator takes the lock,
sees no effective equivalent, creates a replacement, commits; the first revoker
finally acquires the lock, finds its stale tuple still valid, and revokes the
replacement — returning success for an already-revoked target that should have
been not-found. No serial order produces those three results together.

Reproduced deterministically rather than by racing: a test-only connection holds
the exact tuple key, computed with the identical SQL expression the store uses,
while the stale revocation is proven — via `pg_locks`/`pg_stat_activity`, not
assumed — to be blocked on that lock; ordinary administrative statements then
commit the revoked target, its audit row, and the replacement, because the
advisory lock is cooperative. Before the fix the stale revocation fulfilled, the
replacement was revoked, and a `grant.revoke` audit row was written for it.

The correction re-reads the addressed grant after the lock is acquired,
re-asserting every condition the pre-lock read relied on: the exact id, still
unrevoked, still matching the caller's explicit/relationship source guard, and
still carrying the tuple the lock was taken for. A miss is
AUTHORIZATION_GRANT_NOT_FOUND. It is a plain read, not `for update`, so the
tuple advisory lock remains the single create/revoke serialization order and no
second lock ordering or deadlock path is introduced. The broad update stays
untouched, because narrowing it to the addressed id would have stopped clearing
genuine legacy duplicates — the behavior this pass exists to provide.

The comment claiming the target's unrevoked state was re-decided by the update
predicate described a sequence the code did not implement, and is corrected.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 22:32:26 -05:00
ekklesiaandClaude Opus 5 5c589d8fec fix: enforce explicit grant invariants
Explicit-grant management could produce states an operator could not reason
about, and pushed request-shaped problems into the database.

Two identical createGrant calls produced two grants; five concurrent identical
calls produced five. Revoking one returned success while the equivalent kept
authorizing the same user — an operator could believe they had removed access
that was still live. Two issuances are now the same authorization source when
user_id, permission_id, scope, source_kind, and source_ref are all equal, with
NULL equal only to NULL; expiry, creation metadata, actor, and grant id do not
distinguish them. Creating one while an equivalent is currently effective fails
with AUTHORIZATION_GRANT_ALREADY_EXISTS (HTTP 409, no-store) naming nothing about
the grant that already exists, and writing no grant and no audit row. A revoked
or expired equivalent does not block a fresh issuance. Conflict rather than
idempotent return: the caller asked to record a new authorization with their own
actor and expiry, and answering "done" while recording neither would hide that
nothing happened.

Enforcement is database-backed, since nothing constrains the number of API
processes: every create and revoke touching one equivalence tuple takes a
transaction-scoped advisory lock keyed by a hash of that tuple, encoded as a JSON
array so NULL stays distinct from the empty string and a crafted scope cannot
forge a separator. A collision can only make unrelated tuples serialize, never
hide a conflict, because the decision is always an exact SQL predicate. Its
single-key lock space is verified disjoint from the two-key space the migration
runner and bootstrap use. A time-dependent partial unique index was rejected:
now() is not immutable and cannot be indexed.

One revocation now clears every unrevoked equivalent — including expired-but-
unrevoked legacy rows, so the history is left in one state — with one audit row
per grant actually revoked, each carrying its own id, in one transaction.
Differently scoped or sourced grants are untouched. Historical duplicates are not
silently revoked by migration; the store prevents new effective equivalents and
an operator's explicit revocation resolves the class.

The request boundary no longer leaks into PostgreSQL. GrantId accepts exactly the
canonical lowercase hyphenated UUID the database emits, with no trimming and no
case folding, so a malformed path parameter is a 400 decided after authorization
but before any store call instead of reaching the UUID comparison and returning
an indistinguishable 503; a canonical unknown id still returns 404. expiresAt
accepts exactly YYYY-MM-DDTHH:mm:ss.sssZ, byte-for-byte equal to the parsed
date's own toISOString, and must be strictly future — bare new Date silently
rewrote 2026-02-31 to March 3rd and accepted locale, date-only, offset, and
millisecond-free forms, and a past expiry was stored as a grant dead on arrival.
The store validates the same thing before any SQL so trusted internal callers get
the guarantee too.

The generic HTTP surface now rejects a scope field in every form, including null.
No route middleware supplies a scope to the evaluator, so a scope accepted here
produced a grant that persisted, listed, and authorized nothing. Core's scope
semantics are unchanged for internal callers; no resolver was added.

Listing was unbounded and returned a user's entire history. It is now keyset
pagination over created_at DESC, id DESC with a strict boundary on both values,
so rows sharing a timestamp are neither skipped nor repeated, at most limit + 1
rows read, explicit-source filtering server-side on every page, and an opaque
unsigned position cursor that is strictly decoded and reveals nothing on
rejection. Core's list method requires a validated finite bound with no
unbounded fallback anywhere.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 21:56:32 -05:00
ekklesiaandClaude Opus 5 532ce3803b fix: preserve authorization security history
Two security records did not survive user deletion, because both hung off a
cascading foreign key to the live Better Auth user row.

Deleting a user with authorization history deleted that history. A subject with
a create and a revoke audit row had zero rows afterwards: the record of what
they had been granted, by whom, and when it was revoked disappeared at exactly
the moment it mattered most. `ekklesia_core_authorization_audit.subject_user_id`
is now a retained, non-null historical snapshot with no foreign key — a plain
identifier that may name a user row which no longer exists. No name, email, or
other profile data is copied into authorization tables. The Drizzle relation to
the user table is dropped too, because modelling a retained snapshot as a live
relation misdescribes the data. Live rows still cascade, as intended: deleting a
user removes their grants and platform-owner row, so nothing continues
authorizing a principal that no longer exists.

Platform-owner bootstrap's single use was equally fragile. "Already consumed"
meant "a row exists in ekklesia_core_platform_owner", and that table cascades
too — so deleting the bootstrapped owner's user row removed the owner row, its
audit evidence, and with them the only thing preventing the operation that
establishes a platform's first administrator from being run again. A dedicated
`ekklesia_core_platform_owner_bootstrap` singleton now carries that authority.
Its id is pinned by a check constraint to the literal 'singleton', so the
primary key makes a second row structurally impossible rather than merely
unwritten by convention; it has no user foreign key, and Core never deletes from
it. Bootstrap claims that record inside the same transaction as the owner row
and its audit entry, so consumption is tied to success: a missing target user or
any other failure rolls the sentinel back and leaves the one legitimate first
use available. The existing transaction-scoped advisory lock still serializes
concurrent first attempts to exactly one success.

The operational consequence is deliberate and permanent: deleting the last
platform owner does not reactivate bootstrap, and neither does revoking one.

Core migration 0002_authorization_integrity creates the singleton table, drops
the cascading audit-subject constraint, and backfills consumed state for any
installation that already holds a platform-owner row — active or revoked — so an
existing deployment cannot gain a fresh first use by upgrading. It records no
subject, because a backfill cannot know which of several historical owner rows
the original use targeted and guessing would fabricate history. A never-owned
installation is untouched and keeps its one first use.

One upgrade limitation is irrecoverable and is documented rather than papered
over: if an operator manually deleted the bootstrapped owner's user row before
this migration, the old cascade already erased the owner row and its audit
evidence, so nothing remains to detect. At this checkpoint no supported Better
Auth or API user-deletion path can produce that state; it requires direct
database access.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 21:54:51 -05:00
ekklesiaandClaude Opus 5 6b6b77c105 fix: enforce production transport policy
Every PostgreSQL connection this repository opened was plaintext, in every
environment: no connection site set `ssl` at all, so credentials, session
tokens, and row data crossed the network in the clear and nothing in
configuration, startup, or `pnpm doctor` said so. The public identity URL
boundary was also looser than it appeared.

`core/src/database/transport.ts` is now the one typed policy. It has exactly two
modes: `verify-full` (TLS required, certificate chain verified, and server
identity verified against the configured host — the production default) and
`disable` (the development/test default, refused outright in production). There
is deliberately no encryption-only `require` mode, no chain-only `verify-ca`,
and no `rejectUnauthorized: false` escape hatch anywhere in the configuration
surface; every loose or aliased spelling is rejected in both environments, so a
typo never quietly becomes a weaker mode. Production has no plaintext exception
at all, not even for a loopback database — a connection string cannot prove the
database is process-local.

Verification is certificate *and* hostname. The policy supplies an explicit
`checkServerIdentity` pinned to the host parsed from DATABASE_URL, because the
pinned driver sets TLS `servername` only for non-IP hosts and Node performs no
identity check when it is unset — so `rejectUnauthorized: true` alone accepts a
certificate from any host the trusted CA ever signed when connecting by IP.
Both halves are proven against a real TLS-enabled PostgreSQL, including that the
same connection without the pinned callback succeeds. Every CA, certificate, and
key is generated at test runtime and deleted afterwards.

A DATABASE_URL may no longer carry its own TLS parameters. The pinned driver
merges parsed connection-string values over explicitly passed options, so a
`?sslmode=disable` in the URL would silently replace the policy's verified `ssl`
object; that now fails with a stable code, reported by both `pnpm doctor` and
the migration CLIs rather than as "failed unexpectedly".

One shared environment reader keeps `process.env` out of Core's reusable
constructors, and every executable that opens a connection resolves the policy
identically: the API (validated before any pool, Better Auth instance, module
runtime, or listener exists), both migration commands, the bootstrap CLI, and
Doctor's own connection. Doctor reports it as a blocking failure, never a
warning, naming only the effective mode.

Separately, the identity base URL and every trusted origin must now be exactly
one canonical origin — the WHATWG origin serialization must equal the supplied
string — so a path, trailing slash, credentials, explicit default port, or
non-canonical case is rejected rather than normalized into something that no
longer matches what a browser sends. Production's loopback rejection now
classifies the address instead of comparing against three literals, covering
`.localhost`, all of 127.0.0.0/8, 0.0.0.0, `::`, expanded and IPv4-mapped
forms. This is a configuration-shape guarantee only: it does not resolve DNS or
prove reachability, and TLS protects the connection, not the exposure.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 17:03:52 -05:00
ekklesiaandClaude Opus 5 7a0f624fc3 fix: distinguish identity session failures
Principal resolution could not tell a broken session store from a genuinely
anonymous request, and did unnecessary database work for callers that never
presented a session at all. Both are corrected here.

Any cookie previously triggered the ambiguity check, so `Cookie: x=y` on an
otherwise anonymous request caused an identity database round trip an
unauthenticated caller could drive with a header they chose themselves.
`hasConfiguredSessionCookie` now parses the Cookie header per RFC 6265 (split
on `;`, name is the text before the first `=`, trimmed) and compares the whole
name exactly — never `includes`, a substring test, or an unanchored regular
expression — so `better-auth.session_token_x`, `x_better-auth.session_token`, a
differently-cased spelling, and a percent-encoded lookalike are all correctly
not the session cookie. A duplicated name still reports true: ambiguity fails
safe towards checking, and reporting true never grants access on its own.

The cookie name is not a second hard-coded copy. One `betterAuthOptions` object
now feeds both `betterAuth()` and Better Auth's own supported `getCookies`
helper, so the detected name — including the `__Secure-` prefix an https baseURL
implies — is derived from the exact configuration Better Auth itself uses, and
drift is structurally impossible rather than merely tested.

The readiness check itself proved almost nothing: `select 1` still succeeded
against a dropped session table, a drifted or removed column, or a revoked
SELECT privilege, so Better Auth's ambiguous null was reported as a genuinely
anonymous request. `probeSessionStore` now plans a real read of the session
relation and the exact columns the adapter depends on, under `where false`:
PostgreSQL resolves the relation, every column, and the caller's privilege at
plan time while matching zero rows, so no session row, token, or user id can be
read, logged, or enumerated by the check. It uses the injected pool, never a
second connection, and stays bounded by the existing timeout.

A missing relation, dropped column, revoked privilege, unreachable connection,
and query timeout now all produce the same sanitized 503 with no relation name,
column name, SQL, driver text, host, or stack — proven against real PostgreSQL,
since that is the only place a broken store looks exactly like an anonymous
request.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 17:02:42 -05:00
ekklesiaandClaude Opus 5 3d6c85b0f8 fix: observe request timeout failures
The sanitized operational line promised for a header/request timeout was
never actually emitted. Driving a real slow-header and slow-body connection
through a real socket against the pinned Node 24 runtime shows the general
server 'timeout' event never fires for headersTimeout/requestTimeout — that
event belongs to server.setTimeout()'s idle-socket timer, which this server
never arms. Both surface as 'clientError' carrying
err.code === "ERR_HTTP_REQUEST_TIMEOUT".

bindServer now observes 'clientError'. Because registering any such listener
suppresses Node's own default response entirely, the handler reproduces that
default byte-for-byte — 408 for a timeout, 431 for a header overflow, 400
otherwise — writing it only when the socket is still writable and no response
has already begun, then destroying the socket exactly as Node would. Exactly
one fixed sanitized line is logged per event, never the caught error's own
message, which for a parse failure derives from attacker-controlled input.

Proof moves to the real transport boundary: raw-socket scenarios assert the
exact response bytes, exactly one status line, socket closure, handler
non-invocation, and exactly one sanitized log for a slow-header connection, a
slow-body connection, a malformed request line, and an oversized header block.
The prior unit test, which manually emitted 'timeout', is removed as
misleading.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 15:59:33 -05:00
ekklesiaandClaude Opus 5 1161f5a076 fix: fail closed on production signup
Production configuration accepted publicSignupEnabled: true and started a
working Identity/API runtime, even though Ekklesia has no verified email,
invitation requirement, CAPTCHA, or durable shared/edge limiter — Doctor
merely reported "Public signup is explicitly ENABLED" as a pass.

validatePublicSignupEnabled now takes the same IdentityEnvironment the rest
of this config validates against and throws the stable
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION whenever the parsed value is true in
production. Parsing stays separate from the policy, so a malformed value
still reports PUBLIC_SIGNUP_ENABLED_INVALID everywhere. Because the
executable entry point validates configuration before startApi, such a
deployment fails before any pool, Better Auth instance, module runtime, or
listener exists, and Doctor reports NOT READY via its existing
IdentityConfigError path. Development and test composition still enable it
explicitly through the identical typed path.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 15:58:08 -05:00
ekklesiaandClaude Sonnet 5 d3c6bd73d3 fix: enforce API request limits
Add a single platform-owned request-body policy (hono/body-limit) with
small, named limits for Better Auth and Core's grant-mutation endpoint, and
a new required maxBodyBytes field on the executable module route contract so
every module route makes a conscious no-body-or-bounded-body choice, capped
at a platform ceiling. Every oversized request gets one sanitized 413
contract and never reaches business logic, authorization, or a handler.
Also configure finite Node header/request/keep-alive timeouts, including the
non-obvious connectionsCheckingInterval needed for them to take effect
promptly, so a slow-header or slow-body connection cannot hold a socket
indefinitely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 12:29:24 -05:00
ekklesiaandClaude Sonnet 5 cafd513019 fix: secure authentication request identity
Replace Better Auth's default trust of a caller-controlled X-Forwarded-For
with a platform-owned client-address resolution boundary: direct mode (the
safe default) trusts only the real socket peer, and an explicit trusted-proxy
mode requires a header name, a concrete trusted-proxy list, and only honors a
forwarded chain from an actual trusted peer. This closes a rate-limit bypass
where rotating a spoofed header let a caller choose a fresh bucket on every
request. Public sign-up is also disabled by default and requires an explicit
typed opt-in, using Better Auth's own disableSignUp gate so no email-existence
leak or second signup path is introduced.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 12:20:51 -05:00
The_River 5e09330001 Merge pull request 'feat: add module database access' (#13) from phase-5/module-database-access into main
CI / verify (push) Successful in 26s
CI / infrastructure-smoke (push) Successful in 19s
CI / migration-smoke (push) Successful in 28s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/13
2026-07-26 03:01:34 +00:00
ekklesia c38b572778 docs: document module database access
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 29s
Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen
ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit
migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015
where their original text is now inaccurate. Sweep docs/development/api.md,
docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md,
and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness;
correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching
gap in README's current-status narrative. Extend the module template with a permissioned
GET /records route reading through context.services.database, using the canonical
createModuleTable helper, with matching template tests.
2026-07-25 21:50:03 -05:00
ekklesia d1b9efd4d2 test: prove module database access with PostgreSQL
Add a real installed-module fixture (core/tests/fixtures/module-database-integration/db-facade)
with its own manifest, Drizzle schema, drizzle-kit-generated migration, and permissioned HTTP
routes, then exercise it end to end in a new apps/api PostgreSQL integration suite: explicit
migration application before the module runtime starts (never auto-created), start/stop/HTTP
handlers sharing one ModuleDatabase facade by reference, authorized read/mutation/transaction
commit and rollback against real PostgreSQL, denied/rejected-origin/OPTIONS/HEAD requests never
reaching the module or its table, and both a module-caught and an uncaught real database failure
sanitizing exactly as pass 1 established. Registered alongside the existing suites in
vitest.migrations.integration.config.ts; no production code, schema, or migration changed.
2026-07-25 21:26:15 -05:00
ekklesia 0e308eb9ad feat: define module database access
Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen
ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object
through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler
context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView)
over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and
wires it into startApi's fixed startup order ahead of Identity/Authorization construction.
Registration stays database-free; a module reaches its database only through an authorized start,
stop, or HTTP handler invocation.
2026-07-25 20:57:32 -05:00
The_River 9c9ede0418 Merge pull request 'feat: add module HTTP transport' (#12) from phase-5/module-http-transport into main
CI / verify (push) Successful in 26s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 27s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/12
2026-07-25 15:44:40 +00:00
ekklesiaandClaude Sonnet 5 300f98c29d fix: normalize final CORS Vary headers
CI / verify (pull_request) Successful in 26s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 27s
Found during a targeted post-audit review: sanitizeModuleResponse's Vary fix
(bf76f4e) canonicalized the module response's own Vary before Hono's own
cors() middleware (apps/api/src/app.ts) ran, but cors() unconditionally
appends its own "Vary: Origin" afterward, outside module-http-routes.ts's
reach. The wire-level response app.fetch actually returns could therefore
still read "*, Origin" for a module handler that sets Vary: *, or carry a
case-different duplicate Origin token — reproduced first via a new app-level
regression test hitting the final response, not sanitizeModuleResponse in
isolation.

Fixed by wrapping the shared corsMiddleware in withCanonicalVary
(apps/api/src/app.ts), which re-canonicalizes whatever Vary cors() leaves
behind only after it has fully finished, handling both the normal
next()-driven path (via c.header(), since reassigning c.res triggers Hono's
own header-preserving merge and would silently undo the fix) and the
OPTIONS-preflight path (cors() returns a Response directly without touching
c.res). Neither path reads or clones the response body.

Refactored module-http-routes.ts's Vary logic into one shared parsing/dedup
primitive with two policy-specific callers: mergedVaryHeader (module
responses; always forces canonical Origin/Cookie, unchanged behavior) and
the new canonicalizeVaryHeader (the app-level CORS boundary; only
deduplicates and collapses "*", never injects a token that was not already
present — an OPTIONS preflight has no Cookie/personalization concept to
force). This also incidentally fixes the same pre-existing Origin
duplication on /api/v1/principal (present since Phase 4B, flagged as a
remaining concern in the prior pass), since it wraps the one shared
middleware every /api/v1/* and /api/auth/* response already passes through.

ADR 0015 required no changes: its Vary/CORS ownership statements are
architectural, not tied to a specific function, and remain accurate.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-25 10:39:06 -05:00
ekklesiaandClaude Sonnet 5 1285c90618 docs: record module HTTP architecture
Phase 5B, pass 3: adds ADR 0015 recording the durable module HTTP
routing and production runtime lifecycle architecture pass 1 and pass
2 implemented, extending ADR 0006 and ADR 0014 without superseding
either. Adds narrow cross-reference notes to both, and to the ADR
index, pointing at ADR 0015 for the work they deferred, while
preserving their historical text and accepted status.

Corrects stale forward-looking "pass 2"/future-tense language left
over from pass 1 in core/src/modules/runtime.ts and
packages/contracts/src/module-http.ts (comment-only; production
behavior unchanged), and a shutdown-lifecycle description in
docs/development/testing.md that predated the module-runtime-stop
step pass 2 added between draining the HTTP server and closing the
pool.

Full audit against origin/main...HEAD found the architecture,
contract, startup ordering, routing, authorization/origin
enforcement, and PostgreSQL integration coverage from passes 1-2
sound; the one concrete defect found (Vary-header merge producing an
invalid "*, Origin, Cookie" combination and case-sensitive duplicate
tokens) was already fixed with focused regression tests in a separate
commit (bf76f4e), not amending either checkpoint commit.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-25 10:22:35 -05:00
ekklesiaandClaude Sonnet 5 bf76f4e006 fix: collapse invalid Vary * combination and case-duplicate tokens
Found during the Phase 5B pass-3 architectural audit: sanitizeModuleResponse's
Vary merge used a plain Set, so an existing "Vary: *" from a module handler
became "*, Origin, Cookie" (RFC 9110 §12.5.5 forbids combining "*" with any
other value), and a differently-cased existing token (e.g. "origin") produced
a redundant duplicate alongside the forced "Origin"/"Cookie". mergedVaryHeader
now de-duplicates case-insensitively, always emits canonical Origin/Cookie
casing, and collapses to a bare "*" whenever one is present, per focused unit
tests. The full app's own pre-existing cors() middleware (apps/api/src/app.ts,
unchanged here, present since Phase 4B on every /api/v1/* route including
/api/v1/principal) still unconditionally appends its own "Vary: Origin"
outside this module's control — noted in the new tests, not addressed by this
narrow fix.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-25 10:14:13 -05:00
ekklesiaandClaude Sonnet 5 dfaf324a65 feat: mount module HTTP routes
Phase 5B, pass 2: production API mounting, authorization enforcement,
and module-runtime lifecycle integration.

apps/api now discovers the installed module inventory exactly once
(prepareStartupModuleInventory), composes the platform permission
registry from it, starts the module runtime from that same discovered
list (startDiscoveredModuleRuntime, never rediscovering), and mounts
StartedModuleRuntime's published routes under
/api/v1/modules/<module-id> via a new apps/api-owned Hono adapter
(module-http-routes.ts). Every module route is protected by the
existing requirePermission middleware against the composed registry,
state-changing methods additionally require a trusted Origin, OPTIONS
remains entirely platform-owned, and a handler that throws, rejects,
or returns a non-Response becomes a fixed, sanitized
500 MODULE_HANDLER_FAILED. Module-supplied CORS headers are stripped
and every module response is forced to Cache-Control: no-store with
Vary: Origin, Cookie. RunningApi shutdown now stops the module runtime
between draining the HTTP server and closing the shared pool, and
every startup failure path stops the runtime and/or closes the pool
before propagating, without masking the original failure.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-24 19:16:20 -05:00
ekklesiaandClaude Sonnet 5 e296e083ec feat: define module HTTP routes
Phase 5B, pass 1: framework-neutral module HTTP route contracts and runtime
registration. Adds @ekklesia/contracts' module-http.ts (methods, canonical
path grammar, route definition/context/handler types, and validation)
and Core's module runtime staging/publication of a module's declared
routes alongside its capabilities, gated on the module's own manifest
permissions. Routes are collected into StartedModuleRuntime as an
immutable snapshot but are not yet mounted into apps/api; that is pass 2.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-24 18:36:23 -05:00
The_River c9cf1f08e3 Merge pull request 'feat: integrate module authorization' (#11) from phase-5/module-authorization-integration into main
CI / verify (push) Successful in 25s
CI / infrastructure-smoke (push) Successful in 19s
CI / migration-smoke (push) Successful in 26s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/11
2026-07-24 23:05:42 +00:00
ekklesia 510d76f7c2 docs: correct module authorization comments
CI / verify (pull_request) Successful in 26s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 27s
2026-07-24 17:59:48 -05:00
ekklesia 9fa051be61 docs: record module authorization architecture
Adds ADR 0014 recording the durable architecture Phase 5A established
across both passes: the shared contracts-layer PermissionDeclaration,
manifest version 3 replacing version 2's bare permission strings, the
reserved core.* namespace and the structural guarantee that a
module-vs-Core collision always surfaces as
ModulePermissionCompositionError rather than the generic
PermissionRegistryError duplicate path, deterministic registry
composition, startup ordering, repository-root/package-version
resolution, registry injection into apps/api's enforcement and
explicit-grant HTTP API, the static-snapshot/historical-record
preservation guarantees, and Doctor/modules:validate integration.
Indexes it in the ADR README, cross-references it from ADR 0006 (only
its manifestVersion 2 / permission-string decision is superseded; the
rest of ADR 0006 remains accepted, and its historical text is left
untouched) and ADR 0013 (module permission registration, deferred
there, is what ADR 0014 implements), and links it from the relevant
development documentation.

Corrects two comments discovered during a direct source audit that
overstated where a module-vs-Core permission collision is actually
caught (core/src/modules/permissions.ts,
apps/api/src/platform-permissions.ts) — comment-only, no behavior
change, already covered by existing tests. Also corrects a
documentation claim in docs/development/authorization.md that
incorrectly implied removing a module blocks explicit revocation of
its historical grants; grants-routes.ts's revoke handler never
consults the permission registry, so revocation remains unaffected.
2026-07-24 17:49:42 -05:00
ekklesia edd7148aa8 feat: integrate module authorization
Wires composePlatformPermissionRegistry into apps/api's production
startup: startApi() discovers every installed module, composes the one
immutable platform permission registry (Core's own declarations plus
every module's), and only then constructs the shared PostgreSQL pool,
Better Auth, and the HTTP server. A discovery or composition failure —
an invalid manifest, a reserved core.* namespace claim, or a duplicate
declaration — propagates before anything exists to close; there is no
Core-only registry fallback. apps/api/src/platform-permissions.ts
resolves the repository root from its own file location, never
process.cwd(), so package-scoped test runs discover the right modules/
directory.

createApiApp/createGrantsRoutes now require the composed registry as
an injected authorization option, replacing the previous hard-coded
CORE_PERMISSION_REGISTRY; requirePermission continues to work
identically for Core-owned and module-owned permissions, since it has
no concept of the distinction.

Extends pnpm doctor with a modules.permissions check (reusing the
already-discovered module list, distinguishing reserved-namespace from
generic-duplicate failures, never re-discovering modules) and pnpm
modules:validate to compose the same registry before a module is
considered installable.

Adds focused coverage: startup-ordering and failure-isolation tests in
apps/api/tests/lifecycle.test.ts, module-owned-declaration enforcement
tests in apps/api/tests/authorization-middleware.test.ts, Doctor
composition tests in core/tests/doctor.test.ts, and a live PostgreSQL
end-to-end scenario proving a real installed module's permission is
discoverable, grantable, and deniable once the module is absent again
after a restart, without ever executing its server entrypoint.
2026-07-24 14:24:50 -05:00
ekklesia d9dd409aa3 feat: declare module permissions
Establishes the one canonical PermissionDeclaration contract
(packages/contracts/src/authorization.ts) — id/baseline/description,
strict, unknown-property-rejecting — consumed by both Core's
authorization registry and module manifests, replacing Core's
previously separate interface.

Bumps the canonical module manifest to manifestVersion 3: permissions
become complete declarations instead of bare identifier strings, with
manifest-owned namespace and duplicate-id enforcement. Version 2 and
bare string-array permissions are rejected outright; there is no
compatibility adapter.

Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts),
a pure, synchronous composer that builds one platform permission
registry from Core's own declarations plus every already-discovered
module's, in deterministic order, reusing createPermissionRegistry
and rejecting any module-contributed permission in the reserved
core.* namespace. core/src/authorization remains independent of
core/src/modules; only the module integration layer depends on
authorization.

Updates the module template, module-development docs, authorization
docs, and existing manifest fixtures for manifestVersion 3. Does not
wire the combined registry into apps/api — that is pass 2.
2026-07-24 12:25:00 -05:00
The_River 79aaab93b6 Merge pull request 'phase-4/core-authorization' (#10) from phase-4/core-authorization into main
CI / verify (push) Successful in 26s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 28s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/10
2026-07-23 16:52:39 +00:00
ekklesia df2af0a3e0 fix: enforce registered authorization permissions
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 11s
CI / migration-smoke (pull_request) Successful in 27s
Two related fail-closed gaps let a well-formed but never-declared
permission id slip through:

- POST /api/v1/grants created explicit grants for any well-formed
  PermissionId without checking the injected permission registry,
  allowing dormant grants for invented identifiers that could become
  effective the moment those identifiers were registered later.
- createAuthorizationEvaluator validated the permission id only inside
  the restricted-baseline branch, so a malformed permission combined
  with the public or authenticated baseline could still return allowed.

Both now fail closed: the grants route checks the target permission
against the trusted registry before calling store.createGrant (sanitized
403, no store or audit write), and the evaluator validates permission
shape before any baseline is allowed to return an allowed decision,
without adding a registry dependency to the evaluator itself.

The live integration lifecycle test switched from an unregistered
calendar.events.read to the registered core.authorization.grants.manage
permission, since module permission registration does not exist yet.
2026-07-23 11:44:45 -05:00
ekklesia 967c275027 docs: record core authorization architecture
Adds ADR 0013 documenting the durable Phase 4C decisions (permission
registry, evaluator baselines, grant/scope semantics, platform-owner
bootstrap, shared database pool, Origin enforcement, and what remains
deferred), cross-links it from the authorization/README docs, and
refreshes the pnpm doctor no-DATABASE_URL example output in README.md
and repository-workflow.md to include the authorization migration.
2026-07-23 11:26:47 -05:00
ekklesia 1363608552 feat: add authorization bootstrap and HTTP enforcement
Wires Core's identity and authorization domains into apps/api over one
shared, validated PostgreSQL pool; adds an explicit, single-use,
concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible
immutable permission registry with one Core-owned restricted permission
(core.authorization.grants.manage); and adds reusable Hono authorization
enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing
explicit grants only, gated by an exact trusted Origin on every mutation.
2026-07-23 11:16:32 -05:00
ekklesia 72e082ff2f feat: add core authorization foundation 2026-07-23 10:29:01 -05:00
The_River 3fa1c4cdec Merge pull request 'fix: harden identity HTTP transport' (#9) from phase-4/identity-http-hardening into main
CI / verify (push) Successful in 27s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 25s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/9
2026-07-22 05:00:30 +00:00
ekklesia d9b73fd81a fix: harden identity HTTP transport
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 25s
2026-07-21 23:50:41 -05:00
The_River c91f0c8c6d Merge pull request 'feat: expose core identity transport' (#8) from phase-4/identity-http-transport into main
CI / verify (push) Successful in 26s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 29s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/8
2026-07-22 00:24:19 +00:00
ekklesia 91d8f5946f feat: expose core identity transport
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 19s
CI / migration-smoke (pull_request) Successful in 26s
2026-07-21 19:15:14 -05:00
The_River 82fe338a8d Merge pull request 'feat: establish core identity foundation' (#7) from phase-4/core-identity-foundation into main
CI / verify (push) Successful in 23s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 21s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/7
2026-07-21 22:21:28 +00:00
ekklesiaandClaude Sonnet 5 679a5d225c feat: establish core identity foundation
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 22s
Adds Core-owned identity contracts, a Core-owned Better Auth database schema
(ekklesia_core_user/session/account/verification), a first-class Core-owned
migration source that coexists with module-owned migrations under the exact
existing migration system, and an explicit Better Auth construction boundary.
No authentication endpoint or sign-in UI is exposed yet (ADR 0010).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-21 16:39:15 -05:00
The_River df0f20d8af Merge pull request 'feat: add Ekklesia doctor command' (#6) from phase-3/developer-doctor into main
CI / verify (push) Successful in 24s
CI / infrastructure-smoke (push) Successful in 19s
CI / migration-smoke (push) Successful in 24s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/6
2026-07-21 15:45:36 +00:00
ekklesia 02ee3ccd80 feat: add Ekklesia doctor command
CI / verify (pull_request) Successful in 20s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 25s
2026-07-21 10:42:21 -05:00
The_River f89d575dd4 Merge pull request 'fix: build workspace packages before migration tests' (#5) from phase-3/migration-integration-tests into main
CI / verify (push) Successful in 21s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 24s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/5
2026-07-21 15:03:21 +00:00
ekklesia 5104f2e954 fix: build workspace packages before migration tests
CI / verify (pull_request) Successful in 22s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 21s
2026-07-21 09:59:30 -05:00
The_River 262e7b7660 Merge pull request 'test: add PostgreSQL migration integration coverage' (#4) from phase-3/migration-integration-tests into main
CI / verify (push) Successful in 18s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Failing after 18s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/4
2026-07-21 14:50:19 +00:00
ekklesia 831a48187c test: add PostgreSQL migration integration coverage
CI / verify (pull_request) Successful in 19s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Failing after 16s
2026-07-21 09:37:15 -05:00
The_River b38f85f438 Merge pull request 'feat: add transactional module migration runner' (#3) from phase-3/transactional-migration-runner into main
CI / verify (push) Successful in 19s
CI / infrastructure-smoke (push) Successful in 13s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/3
2026-07-21 13:55:10 +00:00
ekklesia 0b282865cb feat: add transactional module migration runner
CI / verify (pull_request) Successful in 19s
CI / infrastructure-smoke (pull_request) Successful in 20s
2026-07-21 08:52:41 -05:00
The_River dc37e59035 Merge pull request 'feat: add module migration contracts and validation' (#2) from phase-3/migration-contracts into main
CI / verify (push) Successful in 18s
CI / infrastructure-smoke (push) Successful in 20s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/2
2026-07-21 13:12:23 +00:00
ekklesia 88c28d75c9 feat: add module migration contracts and validation
CI / verify (pull_request) Successful in 21s
CI / infrastructure-smoke (pull_request) Successful in 20s
2026-07-21 08:09:30 -05:00
The_River 37f54edd89 Merge pull request 'feat: add module schema discovery and validation' (#1) from phase-3/module-schema-discovery into main
CI / verify (push) Successful in 23s
CI / infrastructure-smoke (push) Successful in 20s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/1
2026-07-14 02:44:46 +00:00
ekklesia 80b56b72b1 feat: add module schema discovery and validation
CI / verify (pull_request) Successful in 24s
CI / infrastructure-smoke (pull_request) Successful in 20s
2026-07-13 21:35:21 -05:00
555 changed files with 122162 additions and 356 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
+97 -1
View File
@@ -1,14 +1,110 @@
# Local development only. Replace every credential outside a developer workstation. # Local development only. Replace every credential outside a developer workstation.
#
# `pnpm doctor` loads this file's uncommitted `.env` counterpart and evaluates exactly the host
# infrastructure configuration `pnpm api:start` would use, so the readiness report reflects the real
# deployment without the API process running.
# Required for a local workstation, and only ever correct there. Anything else — including an
# unset value — is validated as production, which requires certificate-verified PostgreSQL TLS
# (EKKLESIA_DATABASE_TLS_MODE defaults to verify-full) and rejects loopback/http identity origins.
# The plaintext loopback DATABASE_URL below is therefore only usable with this set.
NODE_ENV=development
POSTGRES_DB=ekklesia POSTGRES_DB=ekklesia
POSTGRES_USER=ekklesia POSTGRES_USER=ekklesia
POSTGRES_PASSWORD=ekklesia-development-only POSTGRES_PASSWORD=ekklesia-development-only
POSTGRES_PORT=5432 POSTGRES_PORT=5432
DATABASE_URL=postgresql://ekklesia:[email protected]:5432/ekklesia DATABASE_URL=postgresql://ekklesia:[email protected]:5432/ekklesia
# Ekklesia's object-storage provider (ADR 0030). Absent S3_ENDPOINT means this installation has no
# object storage, which is a complete deployment: only a module that declares a logical bucket in its
# manifest is affected, and it is reported blocked rather than breaking anything. Production requires
# https, exactly as the database transport requires verified TLS.
#
# These are host infrastructure. No module ever sees them: a module names a logical bucket and the
# host resolves the binding.
S3_ACCESS_KEY=ekklesia-development S3_ACCESS_KEY=ekklesia-development
S3_SECRET_KEY=ekklesia-development-secret S3_SECRET_KEY=ekklesia-development-secret
S3_BUCKET=ekklesia-development
S3_ENDPOINT=http://127.0.0.1:8333 S3_ENDPOINT=http://127.0.0.1:8333
S3_PORT=8333 S3_PORT=8333
S3_REGION=us-east-1 S3_REGION=us-east-1
S3_FORCE_PATH_STYLE=true S3_FORCE_PATH_STYLE=true
# The origin a *browser* reaches object storage on, used only when signing temporary read URLs for
# direct delivery (ADR 0031). Leave it unset whenever S3_ENDPOINT is itself reachable from a browser,
# which is the case for this development stack: Compose publishes the service on loopback, so the
# endpoint above serves both roles and direct delivery works with no extra configuration.
#
# Set it when Core reaches storage by a name a browser cannot resolve — a Compose service name such
# as http://object-storage:8333, for instance — and browsers reach it through a public origin:
# S3_DELIVERY_ENDPOINT=https://storage.example.org
# It must be the origin the browser actually requests. An S3-compatible signature covers the request
# Host, so a URL signed for one origin cannot be rewritten to another afterwards; a reverse proxy in
# front of storage must therefore pass the original Host header through unchanged. The scheme is not
# signed, so terminating TLS at that proxy and forwarding plaintext to the service is fine.
# S3_DELIVERY_ENDPOINT=
# Namespaces every physical bucket this installation owns, so several Ekklesia-compatible deployments
# can share one S3-compatible service. Optional; defaults to "ekklesia".
S3_BUCKET_PREFIX=ekklesia
# Consumed only by the development Compose service's own bootstrap, never by Ekklesia's provider —
# physical buckets are derived from a module's manifest declaration, not configured here.
S3_BUCKET=ekklesia-development
# Ekklesia's outbound email transport (ADR 0037). Absent EKKLESIA_SMTP_HOST means this installation
# sends no email, which is a complete deployment: nothing in the platform sends any yet, and a
# future feature that needs to is told so rather than crashing.
#
# These are host infrastructure. No module ever sees them, and no feature chooses its own sender:
# the From address below is the only one this platform can send as.
#
# EKKLESIA_SMTP_HOST is a bare hostname — never a URL, a port, or a connection string.
# EKKLESIA_SMTP_SECURITY is exactly one of:
# implicit-tls TLS from the first byte, conventionally port 465
# starttls plaintext connect then a *required* upgrade, conventionally 587 (the default)
# plaintext no TLS at all; development only, and production refuses it outright
# EKKLESIA_SMTP_PORT is optional and defaults to the conventional port for the mode.
#
# Credentials are optional **as a pair**, because a relay authorized by source IP has neither.
# Setting one without the other is refused rather than silently connecting anonymously. Never commit
# a real password: this file is in every clone.
#
# A Google Workspace SMTP Relay deployment typically looks like:
# EKKLESIA_SMTP_HOST=<the relay host from Google Workspace Admin>
# EKKLESIA_SMTP_PORT=587
# EKKLESIA_SMTP_SECURITY=starttls
# [email protected]
# Whether it also needs a username and password depends on the relay's authorization setting in
# Google Workspace Admin, which varies per deployment. See docs/development/email.md.
# EKKLESIA_SMTP_HOST=
# EKKLESIA_SMTP_PORT=587
# EKKLESIA_SMTP_SECURITY=starttls
# EKKLESIA_SMTP_USERNAME=
# EKKLESIA_SMTP_PASSWORD=
# [email protected]
# 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.
+122
View File
@@ -1,5 +1,22 @@
name: CI name: CI
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
# correct on a supported release and on GitHub Actions, but on this server they are inert:
#
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
# is upgraded and configured, the token's authority is whatever the server grants — not what
# this file requests.
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
#
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
# itself. See docs/development/security.md.
on: on:
pull_request: pull_request:
push: push:
@@ -20,6 +37,11 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 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
- 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
@@ -44,6 +66,106 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 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
- name: Exercise PostgreSQL and S3 infrastructure - name: Exercise PostgreSQL and S3 infrastructure
run: bash scripts/smoke-infrastructure.sh run: bash scripts/smoke-infrastructure.sh
migration-smoke:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 15
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
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Activate pinned pnpm
run: |
corepack enable
corepack prepare [email protected] --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Exercise transactional migrations against PostgreSQL
run: pnpm test:migrations:integration
image-build:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 20
env:
# 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
# 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.
- name: Build the production image without modules
run: >-
docker build
--file deploy/Dockerfile
--build-arg REVISION="$GITHUB_SHA"
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"
.
- name: Remove the image from the runner
if: always()
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" || true
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:
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
# 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
+3
View File
@@ -13,3 +13,6 @@ generated/*
*.log *.log
.DS_Store .DS_Store
core/.identity-schema-reference.ts
core/.identity-schema-check-*
+93
View File
@@ -0,0 +1,93 @@
# CLAUDE.md
Guidance for Claude Code (and other coding agents) working in this repository. This file is
intentionally short and durable — it points at authoritative sources instead of restating their
contents, because restated details drift.
## What this repository is
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
monorepo:
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
feature module, a surface, or an app.
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
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
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
`@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.
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
presentation concern a module does not. They consume public API/client contracts only, never Core
or module internals directly.
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
documented. Read `docs/architecture/repository-boundaries.md` and
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
## ADRs are authoritative
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
immutable except for status and cross-reference corrections** — do not edit their content to fit a
new change. A changed decision gets a new ADR that supersedes the old one. Read the full ADR index
(`docs/architecture/decisions/README.md`) and every ADR relevant to the area you are touching
before designing a change; do not assume this file's summary of any decision is complete or
current.
## Read before you design
At minimum, read the current versions of:
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
- `README.md` (current platform status — what is and is not implemented)
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
- `CONTRIBUTING.md`
These files change as the platform evolves. **Inspect the actual current repository state
(source, tests, package.json scripts, CI workflow) before relying on anything in this file or in
your own memory of a prior session** — treat stale assumptions as a bug to catch, not a shortcut.
## Working conventions
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
change branch-protection settings.
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
burying them in an implementation.
- Add or update tests when you change a contract or architectural rule. New behavior needs new
test coverage, not just a passing existing suite.
- Errors that reach a user, log, CLI, or `pnpm doctor` output must be sanitized: never expose
secrets, connection strings, raw SQL, raw driver/exception messages, stack traces, causes, or
machine-specific paths. Match the sanitization pattern already used by
`core/src/migrations/runner.ts` and `core/src/diagnostics/doctor.ts` rather than inventing a new
one.
- Never run a database migration automatically (on import, on startup, in a test that isn't
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
an explicit, operator-invoked action.
## Canonical verification commands
Run from the repository root; `package.json` is the source of truth if this list and the file
ever disagree:
- `pnpm verify` — the full code-only gate (format, lint, boundaries, typecheck, tests, module and
migration validation, identity schema drift check, build). Docker-free; never touches a live
database.
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal
resolution, sign-out). Requires Docker. Run this for any change touching either area.
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
verify` or the integration suite.
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
against `DATABASE_URL`. Never run `migrations:apply` without the user's explicit intent.
- `pnpm api:start` — starts the identity HTTP transport against an already-migrated `DATABASE_URL`.
It never applies migrations itself. Never run it without the user's explicit intent.
## When you're unsure
If a task would require weakening an existing ADR's guarantee, introducing a second mechanism
where one already exists (a second migration runner, a second discovery path, a second config
system), or exposing something the boundary checker currently blocks, stop and surface the
conflict instead of working around it.
+54 -4
View File
@@ -9,6 +9,16 @@ Before opening a change, run:
pnpm verify pnpm verify
``` ```
Migration-runner changes and changes to identity/authentication or the API's session transport also
require the disposable live PostgreSQL suite:
```bash
pnpm test:migrations:integration
```
See the [testing guide](docs/development/testing.md) for the distinction between unit and live
evidence and for credential-safe troubleshooting.
When adding or changing a module manifest, also run `pnpm modules:validate` directly for focused, When adding or changing a module manifest, also run `pnpm modules:validate` directly for focused,
path-aware discovery errors. It is included in `pnpm verify`. path-aware discovery errors. It is included in `pnpm verify`.
@@ -24,10 +34,50 @@ pnpm infrastructure:config
- Expose supported entry points through package `exports`; do not import another package's - Expose supported entry points through package `exports`; do not import another package's
private source paths. private source paths.
- Use `workspace:` ranges for internal package dependencies. - Use `workspace:` ranges for internal package dependencies.
- Keep modules backend-only. Module packages cannot contain React, mobile, stylesheet, or other - Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
UI source files. contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
- Put rendering, layout, navigation, accessibility, and all other presentation concerns in a plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
surface. 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
the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
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
`@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
private deployment detail and would break standalone hosting.
- The containing application shell, navigation, global theme, and platform policy belong to Core and
the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
through `GET /api/v1/module-displays/<module-id>`, mounts both with
`@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
`/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
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
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
ordering, startup, or authorization (ADR 0027). A module declares its display name
(`manifest.name`); the Dashboard owns where it appears.
- A surface is browser code. It may depend on browser-safe shared roles only — never
`@ekklesia/contracts` or `@ekklesia/module-sdk`, whose type graphs pull Node's globals into the
browser — and it reaches the server over HTTP.
- Add or update tests when changing a contract or architectural rule. - Add or update tests when changing a contract or architectural rule.
- Record major architectural changes in an ADR rather than burying them in an implementation. - Record major architectural changes in an ADR rather than burying them in an implementation.
+1022 -9
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@ekklesia/api",
"version": "0.1.1",
"private": true,
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/app.d.ts",
"import": "./dist/app.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*",
"@ekklesia/core": "workspace:*",
"@ekklesia/shell": "workspace:*",
"@hono/node-server": "2.0.10",
"hono": "4.12.31"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3",
"@types/pg": "8.20.0",
"@types/smtp-server": "3.5.13",
"better-auth": "1.6.23",
"happy-dom": "20.0.11",
"pg": "8.22.0",
"smtp-server": "3.19.3",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "app"
}
}
+507
View File
@@ -0,0 +1,507 @@
import {
isAuthenticatedPrincipal,
type ModuleRuntimeServices,
type ModuleSecretSource,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import type {
AuthorizationEvaluator,
AuthorizationStore,
PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { DashboardStore } from "@ekklesia/core/dashboard";
import type { DeviceStore } from "@ekklesia/core/devices";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
import type {
DiscoveredModule,
ModuleConfigurationLookup,
ModuleConfigurationSettings,
ModuleConfigurationStore,
ModuleLifecycleSnapshot,
ModuleLifecycleStore,
ModuleRealtimeRegistry,
ModuleStorageLookup,
ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules";
import type { Context, MiddlewareHandler } from "hono";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
import { createDashboardRoutes } from "./dashboard-routes.js";
import { createDeviceRoutes } from "./device-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js";
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
import { createModuleUiRoutes } from "./module-ui-routes.js";
export interface CreateApiAppAuthorizationOptions {
readonly evaluator: AuthorizationEvaluator;
/** The complete platform permission registry (Core's own declarations plus every installed
* module's) — always the caller-composed registry `startApi` produces via
* `prepareStartupModuleInventory`, never a Core-only fallback constructed here. */
readonly registry: PermissionRegistry;
readonly store: AuthorizationStore;
}
export interface CreateApiAppOptions {
readonly authorization: CreateApiAppAuthorizationOptions;
/** The started module runtime's published, immutable route snapshot — always the caller-started
* runtime's exact `routes` (`startDiscoveredModuleRuntime`, `apps/api/src/server.ts`), mounted in
* publication order under `/api/v1/modules/<module-id>`. Required rather than defaulted so a
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
* implicit fallback. */
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
* empty, for the same reason as module routes: application composition never guesses what ran. */
readonly moduleRealtime: ModuleRealtimeRegistry;
/** The same frozen services object the caller's started module runtime already passed to every
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
* `ModuleDatabase` for the lifetime of this API runtime. */
readonly moduleServices: ModuleRuntimeServices;
/** The platform's module UI publication policy — always the one Core builds from the exact
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
* is: a caller that publishes no module UI must say so explicitly with an empty registry, never
* fall through to an implicit one. See `./module-ui-routes.js`. */
readonly moduleUi: ModuleUiPublicationRegistry;
/**
* Managed display devices. Optional so every existing composition — and every test that has no
* interest in devices — builds unchanged; when it is absent no pairing route is mounted, no
* device cookie is ever read, and a device principal simply cannot come into being.
*/
readonly devices?: {
readonly store: DeviceStore;
/** Whether the deployment is reached over https, so the device cookies can assert `Secure`
* without becoming unusable on a plaintext development origin. */
readonly secureCookies: boolean;
};
/** Surface-owned display host assets, read once by production startup. Optional only when the
* eligible publication registry contains no display entrypoint; route composition rejects an
* inconsistent display-bearing registry synchronously. */
readonly displayHostAssets?: DisplayHostAssets;
/**
* The platform's operational module lifecycle: the store holding which modules are enabled, and the
* boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028).
* Required rather than defaulted, like every other module option here — a caller exercising no
* modules says so with an empty snapshot, never falls through to an implicit one.
*
* The snapshot must come from the same boot as `moduleRoutes` and `moduleUi`; that is what makes
* "running" an observation of this process rather than a prediction. See
* `./module-lifecycle-routes.js`.
*/
readonly moduleLifecycle: {
readonly snapshot: ModuleLifecycleSnapshot;
readonly store: ModuleLifecycleStore;
};
/**
* The platform's module configuration resource (ADR 0029): the discovered inventory whose
* manifests declare what may be configured, the settings this process resolved at boot (used only
* to compute `restartRequired`), the deployment's secret source, and the store.
*
* Required rather than defaulted, like every other module option here. `secrets` is explicit for
* the same reason: a caller that supplies no module secrets says so, and a module requiring one is
* then reported `secret_missing` rather than silently starting without it.
*/
readonly moduleConfiguration: {
readonly discoveredModules: readonly DiscoveredModule[];
readonly bootSettings: ModuleConfigurationSettings;
readonly secrets: ModuleSecretSource;
readonly store: ModuleConfigurationStore;
/** The per-module frozen snapshots the started runtime is actually using — the identical map
* `startDiscoveredModuleRuntime` received, so a module's HTTP handler and its lifecycle hooks
* read the same object rather than two independently resolved values. */
readonly snapshots: ModuleConfigurationLookup;
};
/**
* The platform's module object storage (ADR 0030): the per-module scoped facades the runtime
* started with, and whether a host provider is configured at all.
*
* Required rather than defaulted, like every other module option here — a caller exercising no
* storage says so with an empty map and `providerConfigured: false`, never falls through to an
* implicit one. Nothing here carries an endpoint, a region, or a credential: those never leave
* Core's provider.
*/
readonly moduleStorage: {
readonly snapshots: ModuleStorageLookup;
readonly providerConfigured: boolean;
};
/** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required
* rather than defaulted, like every other module option here: a caller that exercises no
* Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is
* presentation only — nothing reachable through this store influences discovery, dependency
* ordering, module startup, or any authorization decision. */
readonly dashboard: DashboardStore;
/**
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
* shell build fails startup instead of leaving this origin answering `/` with a 404. See
* `./dashboard-host-routes.js`.
*/
readonly dashboardHostAssets: DashboardHostAssets;
/**
* The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than
* defaulted, like every other dependency here: a composition that cannot reach a database says so
* with a check that rejects, never falls through to an implicitly healthy one. `checkDatabase` is
* the bare query; the routes themselves bound and serialize it. See `./health-routes.js`.
*/
readonly health: ApiHealthOptions;
readonly runtime: EkklesiaIdentityRuntime;
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
* `requireTrustedOrigin` checks for grant- and module-route-mutation routes — there is no second,
* competing origin configuration. */
readonly trustedOrigins: readonly string[];
}
function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
const code = error instanceof IdentityRuntimeError ? error.code : "INTERNAL_ERROR";
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
const EMAIL_SIGN_IN_PATH = "/api/auth/sign-in/email";
const EMAIL_SIGN_IN_SUCCESS_BODY = JSON.stringify({ ok: true });
/**
* Better Auth publishes general-purpose session-management endpoints even when an application has
* no client for them. These four can return the raw bearer-equivalent session token with the
* pinned configuration: get/list/update return a session record; change-password returns a newly
* minted token when asked to revoke other sessions. Ekklesia deliberately exposes only its narrow
* `/api/v1/principal` projection and has no native session/password-management browser UI, so the
* supported method/path pairs are closed at the outer HTTP boundary before Better Auth executes.
*/
const TOKEN_BEARING_AUTH_ENDPOINT_METHODS: Readonly<Record<string, readonly string[]>> =
Object.freeze({
"/api/auth/change-password": Object.freeze(["POST"]),
"/api/auth/get-session": Object.freeze(["GET", "POST"]),
"/api/auth/list-sessions": Object.freeze(["GET"]),
"/api/auth/update-session": Object.freeze(["POST"]),
});
function decodedRequestPathname(request: Request): string | undefined {
const encodedPathname = new URL(request.url).pathname;
try {
// URL.pathname intentionally preserves percent escapes. Decode exactly once before comparing,
// matching the maximum decoding a downstream HTTP router may safely perform; malformed
// encodings are denied rather than delegated through an ambiguous parser boundary.
return decodeURIComponent(encodedPathname);
} catch {
return undefined;
}
}
function isDisabledTokenBearingAuthRequest(request: Request): boolean {
const pathname = decodedRequestPathname(request);
if (pathname === undefined) {
return true;
}
// Better Auth is configured not to skip trailing slashes, but treating trailing-slash spellings
// as the same denied resource prevents a router-option change from opening a bypass. This does
// not collapse interior duplicate slashes or otherwise broaden path normalization.
const canonicalPath = pathname.replace(/\/+$/u, "") || "/";
return TOKEN_BEARING_AUTH_ENDPOINT_METHODS[canonicalPath]?.includes(request.method) ?? false;
}
/**
* Better Auth's native successful email sign-in response includes the newly created session token
* and the complete user object in its JSON body even though the browser already received the
* session through an HttpOnly cookie. The browser boundary must never make either value visible to
* application JavaScript. Keep this deliberately narrower than a general Better Auth response
* adapter: only the exact supported POST endpoint and a body-bearing successful response are
* normalized. Every sign-in failure and every other delegated auth endpoint remains Better Auth's
* native contract; the explicitly denied token-bearing endpoints above are never delegated.
*/
function browserSafeAuthResponse(request: Request, response: Response): Response {
const headers = new Headers(response.headers);
headers.set("Cache-Control", "no-store");
if (
request.method === "POST" &&
decodedRequestPathname(request) === EMAIL_SIGN_IN_PATH &&
response.ok &&
response.status !== 204 &&
response.status !== 205
) {
// The upstream length describes the deliberately discarded token/user JSON. Retaining it could
// truncate or stall the replacement payload once an HTTP adapter serializes this response.
headers.delete("Content-Length");
headers.set("Content-Type", "application/json; charset=UTF-8");
return new Response(EMAIL_SIGN_IN_SUCCESS_BODY, {
headers,
status: response.status,
statusText: response.statusText,
});
}
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
}
/**
* Hono's own `cors()` unconditionally appends its own "Vary: Origin" after every response with a
* dynamic-function `origin` — including one that already has its own `Vary` (module responses
* force `Origin`/`Cookie`; `/api/v1/principal` sets `Origin, Cookie` directly; an `OPTIONS`
* preflight sets its own) — so the header `cors()` itself leaves behind can still be an invalid
* combined "*, Origin" or contain a case-different duplicate. This wraps `middleware` and
* canonicalizes whatever `Vary` remains only *after* it has fully finished, using
* `canonicalizeVaryHeader` — the same shared parsing/dedup primitive `mergedVaryHeader` (module
* responses) builds on, so there is one implementation to keep in sync, not two that could diverge
* — but never `mergedVaryHeader` itself: this boundary wraps *every* response, `OPTIONS` preflights
* included, and must never inject `Origin`/`Cookie` onto a response that never had them, only
* de-duplicate what is already there. Two return shapes have to be handled: a normal request lets
* `middleware` call `next()` and mutate `c.res` in place, so the fix-up goes through `c.header()`
* (reassigning `c.res` directly triggers Hono's own header-preserving merge and would silently
* restore the uncanonicalized value); an `OPTIONS` preflight makes `cors()` return a new `Response`
* directly without ever touching `c.res`, so the fix-up mutates that returned response's headers
* instead. Neither path reads or clones the response body, so a streaming body is untouched either
* way. Status, body, content type, ordinary headers, authorization, trusted-origin behavior, and
* route ordering are all unaffected — only the final `Vary` value changes.
*/
function withCanonicalVary(middleware: MiddlewareHandler): MiddlewareHandler {
return async (c, next) => {
const result = await middleware(c, next);
if (result instanceof Response) {
const existingVary = result.headers.get("Vary");
if (existingVary !== null) {
result.headers.set("Vary", canonicalizeVaryHeader(existingVary));
}
return result;
}
const existingVary = c.res.headers.get("Vary");
if (existingVary !== null) {
c.header("Vary", canonicalizeVaryHeader(existingVary));
}
return result;
};
}
/**
* Pure Hono application factory: no I/O, no environment reads, no process side effects, and it
* never invokes a module route handler while composing routes. Suitable for unit and integration
* tests via `app.fetch` directly, with a fully injected identity runtime and authorization
* evaluator/store standing in for real Better Auth/PostgreSQL where appropriate. Synchronously
* throws `ModuleRouteConfigurationError` (`./module-http-routes.js`) if `moduleRoutes` and
* `authorization.registry` are inconsistent — a route naming a permission the registry does not
* register, which should never happen since both derive from the same discovered inventory, but is
* checked explicitly rather than silently mounting an unenforceable route.
*/
export function createApiApp(options: CreateApiAppOptions): Hono {
const { runtime } = options;
const allowedOrigins = new Set(options.trustedOrigins);
const app = new Hono();
const corsMiddleware = withCanonicalVary(
cors({
allowHeaders: ["Content-Type"],
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
credentials: true,
maxAge: 600,
origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined),
}),
);
app.use("/api/auth/*", corsMiddleware);
app.use("/api/v1/*", corsMiddleware);
// This gate intentionally precedes the body limiter and the generic Better Auth delegate. A
// denied POST neither reads an attacker-controlled body nor executes any identity-runtime code;
// it always has the platform's fixed, non-secret 404 shape. Query strings do not affect the
// pathname comparison, and the CORS middleware above still applies the exact trusted-origin
// policy to the response.
app.on(["POST", "GET"], "/api/auth/*", async (c, next) => {
if (!isDisabledTokenBearingAuthRequest(c.req.raw)) {
return next();
}
c.header("Cache-Control", "no-store");
return c.json({ code: "NOT_FOUND" }, 404);
});
const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES);
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
try {
const response = await runtime.handleAuthRequest(c.req.raw);
// Every Better Auth response — including a session cookie — must never be cached by an
// intermediary; constructing a fresh Response (rather than relying on `c.header()`, which
// only affects `c.res`) guarantees this regardless of how the handler produced its result,
// and never reads or buffers the body.
return browserSafeAuthResponse(c.req.raw, response);
} catch (error) {
return sanitizedErrorResponse(c, error, 500);
}
});
app.get("/api/v1/principal", async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
try {
const principal = await runtime.resolvePrincipal(c.req.raw.headers);
return c.json(
isAuthenticatedPrincipal(principal)
? { kind: "authenticated" as const, userId: principal.userId }
: { kind: "anonymous" as const },
200,
);
} catch (error) {
return sanitizedErrorResponse(c, error, 503);
}
});
// Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below,
// it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules
// own entirely. See `./health-routes.js`.
app.route("/", createHealthRoutes(options.health));
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
// `./dashboard-host-routes.js`.
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
app.route(
"/",
createGrantsRoutes({
evaluator: options.authorization.evaluator,
registry: options.authorization.registry,
runtime,
store: options.authorization.store,
trustedOrigins: allowedOrigins,
}),
);
// A platform-owned route, mounted exactly like Core's own: it serves only artifacts the injected
// publication registry already approved, and it can never collide with a module's own routes,
// which all live under `/api/v1/modules/`. See `./module-ui-routes.js` for why it is a sibling of
// that prefix rather than nested inside it.
app.route("/", createModuleUiRoutes({ registry: options.moduleUi }));
// The browser host's bootstrap query, at the collection root of that same prefix. Unlike the
// artifact route above it is authenticated (`core.modules.ui.read`) and per-principal, because it
// is the module enumeration ADR 0025 deliberately withheld from the anonymous artifact route.
app.route(
"/",
createModuleUiInventoryRoutes({
evaluator: options.authorization.evaluator,
moduleUi: options.moduleUi,
registry: options.authorization.registry,
runtime,
}),
);
// A separate, chrome-free browser host. Exact page and descriptor routes are derived from the
// same eligible/running publication registry as artifact bytes; a display-only module never
// enters Dashboard navigation, and no request value can select a file or permission.
mountModuleDisplayRoutes(app, {
evaluator: options.authorization.evaluator,
moduleUi: options.moduleUi,
registry: options.authorization.registry,
runtime,
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
});
if (options.devices) {
// Host-owned device identity. Siblings of `/api/v1/modules/...`, reachable by no module, and
// mounted only when the deployment actually composed device support.
app.route(
"/",
createDeviceRoutes({
devices: options.devices.store,
evaluator: options.authorization.evaluator,
moduleUi: options.moduleUi,
registry: options.authorization.registry,
runtime,
secureCookies: options.devices.secureCookies,
trustedOrigins: allowedOrigins,
}),
);
}
// Layered deliberately on top of the generic inventory above rather than merged into it: the
// inventory stays the host-neutral "what is mountable" contract a standalone host could consume,
// and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`.
app.route(
"/",
createDashboardRoutes({
evaluator: options.authorization.evaluator,
moduleUi: options.moduleUi,
registry: options.authorization.registry,
runtime,
store: options.dashboard,
trustedOrigins: allowedOrigins,
}),
);
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
// rather than inside it. See `./module-lifecycle-routes.js`.
app.route(
"/",
createModuleLifecycleRoutes({
evaluator: options.authorization.evaluator,
registry: options.authorization.registry,
runtime,
configuration: {
secrets: options.moduleConfiguration.secrets,
store: options.moduleConfiguration.store,
},
storage: { providerConfigured: options.moduleStorage.providerConfigured },
snapshot: options.moduleLifecycle.snapshot,
store: options.moduleLifecycle.store,
trustedOrigins: allowedOrigins,
}),
);
// Same sibling-prefix rule as the lifecycle resource above: `/api/v1/modules/` belongs entirely
// to installed modules. See `./module-configuration-routes.js`.
app.route(
"/",
createModuleConfigurationRoutes({
bootSettings: options.moduleConfiguration.bootSettings,
discoveredModules: options.moduleConfiguration.discoveredModules,
evaluator: options.authorization.evaluator,
registry: options.authorization.registry,
runtime,
secrets: options.moduleConfiguration.secrets,
store: options.moduleConfiguration.store,
trustedOrigins: allowedOrigins,
}),
);
// A sibling platform-owned prefix, never nested under `/api/v1/modules/` where installed modules
// own every path. One exact route per runtime-published channel, authorized before subscription.
mountModuleRealtimeRoutes(app, {
evaluator: options.authorization.evaluator,
realtime: options.moduleRealtime,
registry: options.authorization.registry,
runtime,
});
mountModuleHttpRoutes(app, options.moduleRoutes, {
configurations: options.moduleConfiguration.snapshots,
storages: options.moduleStorage.snapshots,
evaluator: options.authorization.evaluator,
registry: options.authorization.registry,
realtime: options.moduleRealtime,
runtime,
services: options.moduleServices,
trustedOrigins: allowedOrigins,
});
app.notFound((c) => {
c.header("Cache-Control", "no-store");
return c.json({ code: "NOT_FOUND" }, 404);
});
app.onError((_error, c) => sanitizedErrorResponse(c, _error, 500));
return app;
}
+111
View File
@@ -0,0 +1,111 @@
import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts";
import type { AuthorizationEvaluator, PermissionRegistry } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import type { Context, MiddlewareHandler, Next } from "hono";
const PRINCIPAL_CONTEXT_KEY = "ekklesiaPrincipal";
export interface AuthorizationMiddlewareOptions {
readonly evaluator: AuthorizationEvaluator;
readonly registry: PermissionRegistry;
readonly runtime: EkklesiaIdentityRuntime;
}
function sanitizedDenial(c: Context, status: 403 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/**
* Builds a Hono middleware that enforces `permissionId`, resolved exclusively from the injected,
* trusted, server-side `PermissionRegistry` — in production, the complete platform registry
* `startApi` composes from Core's own declarations plus every installed module's, never a
* Core-only fallback. A route never supplies its own baseline, and no request input can influence
* which baseline is checked. The principal is resolved exclusively
* through the injected identity runtime (`resolvePrincipal`), the same one `/api/v1/principal`
* already uses; nothing here ever trusts a client-supplied claim about identity, ownership, or
* grants. A permission id that is not registered fails closed (403) exactly like a denied
* decision — in normal operation this route only ever hard-codes an already-registered constant,
* but the check stays live per-request as defense in depth rather than being trusted once at
* composition time.
*
* On success, the resolved `EkklesiaPrincipal` — Core's own narrow `{kind, userId}` contract, not
* Better Auth's richer session/user object — is stored on the Hono context so the route handler
* can build an actor without resolving the principal a second time. This is deliberately narrower
* than the "no generalized session/user object in Hono context" concern ADR 0011 raised about
* Better Auth's own documented example: only Core's already-public-safe principal shape is ever
* set, never a session token, raw user row, or Better Auth type.
*
* A genuine infrastructure failure — principal resolution, evaluation, or the underlying
* database — is never folded into a 403; it returns a sanitized 503, mirroring
* `/api/v1/principal`'s existing behavior for the identical reason: a caller must never see "no
* permission" when the true answer is "could not be determined."
*/
export function requirePermission(
options: AuthorizationMiddlewareOptions,
permissionId: PermissionId,
): MiddlewareHandler {
return async (c: Context, next: Next) => {
const declaration = options.registry.get(permissionId);
if (!declaration) {
return sanitizedDenial(c, 403, "AUTHORIZATION_DENIED");
}
let principal: EkklesiaPrincipal;
try {
principal = await options.runtime.resolvePrincipal(c.req.raw.headers);
} catch {
return sanitizedDenial(c, 503, "PRINCIPAL_RESOLUTION_FAILED");
}
let decision: { allowed: boolean };
try {
decision = await options.evaluator.authorize({
baseline: declaration.baseline,
permission: declaration.id,
principal,
});
} catch {
return sanitizedDenial(c, 503, "AUTHORIZATION_EVALUATION_FAILED");
}
if (!decision.allowed) {
return sanitizedDenial(c, 403, "AUTHORIZATION_DENIED");
}
c.set(PRINCIPAL_CONTEXT_KEY, principal);
await next();
return;
};
}
/** Reads the principal a preceding `requirePermission` middleware already resolved and verified.
* Throws (a route-wiring bug, not a runtime condition) if called on a route this middleware never
* protected. */
export function getAuthorizedPrincipal(c: Context): EkklesiaPrincipal {
const principal = c.get(PRINCIPAL_CONTEXT_KEY) as EkklesiaPrincipal | undefined;
if (!principal) {
throw new Error("getAuthorizedPrincipal called outside a requirePermission-protected route.");
}
return principal;
}
/**
* Requires an exact trusted `Origin` header for a cookie-authenticated, state-changing route.
* CORS response headers alone are not CSRF protection — they only stop a browser from *reading* a
* cross-origin response, not from *sending* the state-changing request in the first place — so
* mutation routes need this explicit, server-side check in addition to (not instead of) the
* existing CORS policy. A missing or untrusted origin is rejected before the route (or the
* permission check) ever runs. Non-browser, non-cookie automation (service accounts/API tokens)
* is deliberately out of scope for this slice and has no way to satisfy this check yet.
*/
export function requireTrustedOrigin(trustedOrigins: ReadonlySet<string>): MiddlewareHandler {
return async (c: Context, next: Next) => {
const origin = c.req.header("origin");
if (!origin || !trustedOrigins.has(origin)) {
return sanitizedDenial(c, 403, "UNTRUSTED_ORIGIN");
}
await next();
return;
};
}
+218
View File
@@ -0,0 +1,218 @@
import {
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
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
* across every route file. Every limit below is deliberately small relative to the actual payload
* shapes it guards:
*
* - `AUTH_REQUEST_MAX_BODY_BYTES` (8 KiB): every enabled Better Auth route
* (`/api/auth/sign-up/email`, `/sign-in/email`, `/sign-out`, ...) accepts only an email, a
* password (bounded to 128 characters — `core/src/identity/runtime.ts`), and an optional short
* display name; the pinned release adds no other enabled provider or field (ADR 0011). A real
* payload is well under 200 bytes; 8 KiB leaves generous headroom for a verbose email address or
* name while remaining two orders of magnitude below the module ceiling.
* - `CORE_MUTATION_MAX_BODY_BYTES` (16 KiB): the one Core JSON mutation body, explicit-grant
* creation (`POST /api/v1/grants`), accepts `subjectUserId` (<=128 chars), `permission` (a bounded
* identifier), an optional `scope` (<=256 chars), and an optional ISO `expiresAt` string — every
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
* abuse.
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
* constant a module's own **buffered** route declaration is validated against at registration) is
* the ceiling for every body the platform materializes before a handler runs. It has not moved.
* - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it
* explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as
* `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 CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
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
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
* size, a declared Content-Length, or any body content. */
export function requestBodyTooLargeResponse(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
}
/**
* A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one.
*
* A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately:
* the byte counting below bounds the request either way, so refusing here could only ever turn a
* legitimate request away without making an illegitimate one any less bounded. Node's own HTTP
* parser rejects genuinely malformed framing before this code sees the request at all.
*/
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;
};
}
+66
View File
@@ -0,0 +1,66 @@
import { type ClientAddressResolver, TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
type NodeServerBindings = HttpBindings | Http2Bindings;
export {
type ClientAddressConfig,
ClientAddressConfigError,
type ClientAddressConfigErrorCode,
type ClientAddressConfigInput,
type ClientAddressFailureReason,
type ClientAddressMode,
type ClientAddressResolution,
type ClientAddressResolver,
createClientAddressConfig,
createClientAddressResolver,
type DirectClientAddressConfig,
findInvalidTrustedProxies,
normalizeClientAddress,
type TrustedProxyClientAddressConfig,
} from "@ekklesia/core/identity";
/** A fixed, generic diagnostic — never the peer address, forwarded header content, request path, or
* any other request detail — logged only when client-address resolution fails closed. */
export const CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE =
"[client-address] A request's caller address could not be resolved safely; rejecting with 503.";
function sanitizedClientAddressUnresolvedResponse(): Response {
return new Response(JSON.stringify({ code: "CLIENT_ADDRESS_UNRESOLVED" }), {
headers: { "cache-control": "no-store", "content-type": "application/json" },
status: 503,
});
}
/**
* Wraps a `@hono/node-server` fetch callback at the exact Node/API transport boundary — the one
* place in this repository below `apps/api` that reads the raw Node `IncomingMessage` — with
* Core's framework-agnostic client-address resolution policy (`@ekklesia/core/identity`). Resolves
* the caller's trusted address from the real socket peer (`env.incoming.socket.remoteAddress`)
* before the wrapped callback — and therefore Better Auth's own rate limiter — ever sees the
* request, then overwrites (never merely appends) any externally supplied copy of
* `TRUSTED_CLIENT_ADDRESS_HEADER` with that resolved value. A caller can therefore never choose its
* own resolved address, whether by spoofing a forwarded header or by sending the internal header
* directly. Resolution failure never reaches the wrapped callback at all — no Better Auth logic, no
* Core mutation, and no module handler ever runs for a request whose caller address could not be
* resolved safely.
*/
export function wrapFetchWithClientAddress(
fetchCallback: (request: Request, env: NodeServerBindings) => Response | Promise<Response>,
resolver: ClientAddressResolver,
): (request: Request, env: NodeServerBindings) => Promise<Response> {
return async (request: Request, env: NodeServerBindings): Promise<Response> => {
const resolution = resolver.resolve({
headers: request.headers,
peerAddress: env.incoming.socket.remoteAddress,
});
if (!resolution.ok) {
console.error(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
return sanitizedClientAddressUnresolvedResponse();
}
const headers = new Headers(request.headers);
headers.set(TRUSTED_CLIENT_ADDRESS_HEADER, resolution.address);
const sanitizedRequest = new Request(request, { headers });
return fetchCallback(sanitizedRequest, env);
};
}
+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;
});
}
+218
View File
@@ -0,0 +1,218 @@
import {
assertProductionDatabaseTransport,
createDatabaseTransportPolicy,
type DatabaseTransportPolicy,
type DatabaseTransportPolicyInput,
} from "@ekklesia/core/database";
import {
type CreateIdentityConfigOptions,
createIdentityConfig,
type IdentityConfig,
type IdentityConfigInput,
} from "@ekklesia/core/identity";
import {
createMailProviderConfig,
type MailProviderConfig,
type MailProviderConfigInput,
} from "@ekklesia/core/mail";
import {
createStorageProviderConfig,
type StorageProviderConfig,
type StorageProviderConfigInput,
} from "@ekklesia/core/storage";
import {
type ClientAddressConfig,
type ClientAddressConfigInput,
createClientAddressConfig,
} from "./client-address.js";
export type ApiConfigErrorCode =
| "DATABASE_URL_INVALID"
| "DATABASE_URL_MISSING"
| "HOST_INVALID"
| "PORT_INVALID";
/** Never carries the offending database URL, host, port, or any other caller-supplied value. */
export class ApiConfigError extends Error {
readonly code: ApiConfigErrorCode;
constructor(code: ApiConfigErrorCode) {
super(apiConfigErrorMessages[code]);
this.name = "ApiConfigError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
const apiConfigErrorMessages: Readonly<Record<ApiConfigErrorCode, string>> = Object.freeze({
DATABASE_URL_INVALID:
"The API database URL must be an absolute postgres:// or postgresql:// URL.",
DATABASE_URL_MISSING: "The API database URL is required.",
HOST_INVALID: "The API listen address must be a non-empty host.",
PORT_INVALID:
"The API listen port must be an integer between 0 and 65535 (0 requests an OS-assigned port).",
});
const defaultHost = "127.0.0.1";
const defaultPort = 3000;
export interface ApiRuntimeConfig {
readonly clientAddress: ClientAddressConfig;
readonly databaseUrl: string;
/** The one validated PostgreSQL transport policy this runtime's single pool uses. */
readonly databaseTransport: DatabaseTransportPolicy;
readonly host: string;
readonly identity: IdentityConfig;
readonly port: number;
/**
* The validated host object-storage provider, or `undefined` when this installation has none
* (ADR 0030).
*
* `undefined` is a complete, valid deployment: modules that declare no logical buckets are
* unaffected, and a module that does declare one is reported blocked rather than crashing
* anything. These values reach Core's provider and nowhere else — no module, module UI, browser
* bundle, diagnostic, or API response ever sees the endpoint or the credentials.
*/
readonly storage: StorageProviderConfig | undefined;
/**
* The validated host outbound-email provider, or `undefined` when this installation has none
* (ADR 0037).
*
* `undefined` is a valid deployment: the platform starts, every existing feature works, and the
* 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
* API response ever sees the host or the password.
*/
readonly mail: MailProviderConfig | undefined;
}
export interface ApiRuntimeConfigInput {
/** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy
* trust unless explicitly configured. See `./client-address.js`. */
readonly clientAddress?: ClientAddressConfigInput | undefined;
readonly databaseUrl?: unknown;
/** Optional. Defaults to certificate-verified TLS in production and plaintext in development;
* production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */
readonly databaseTransport?: DatabaseTransportPolicyInput;
readonly host?: unknown;
readonly identity: IdentityConfigInput;
readonly port?: unknown;
/** Optional. Omitted, or with no endpoint, means this installation has no object storage. */
readonly storage?: StorageProviderConfigInput;
/** Optional. Omitted, or with no host, means this installation sends no outbound email. */
readonly mail?: MailProviderConfigInput;
}
export interface CreateApiRuntimeConfigOptions {
readonly environment?: CreateIdentityConfigOptions["environment"];
}
function validateDatabaseUrl(databaseUrl: unknown): string {
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
throw new ApiConfigError("DATABASE_URL_MISSING");
}
let protocol: string;
try {
protocol = new URL(databaseUrl).protocol;
} catch {
throw new ApiConfigError("DATABASE_URL_INVALID");
}
if (protocol !== "postgres:" && protocol !== "postgresql:") {
throw new ApiConfigError("DATABASE_URL_INVALID");
}
return databaseUrl;
}
function validateHost(host: unknown): string {
if (host === undefined) {
return defaultHost;
}
if (typeof host !== "string" || host.trim().length === 0) {
throw new ApiConfigError("HOST_INVALID");
}
return host;
}
function validatePort(port: unknown): number {
if (port === undefined) {
return defaultPort;
}
const numericPort = typeof port === "string" ? Number(port) : port;
if (
typeof numericPort !== "number" ||
!Number.isInteger(numericPort) ||
numericPort < 0 ||
numericPort > 65_535
) {
throw new ApiConfigError("PORT_INVALID");
}
return numericPort;
}
/**
* Validates explicit, typed API runtime configuration, delegating identity fields to Core's
* existing `createIdentityConfig` rather than defining a competing identity configuration
* contract. Performs no environment reads, network calls, or database access.
*/
export function createApiRuntimeConfig(
input: ApiRuntimeConfigInput,
options: CreateApiRuntimeConfigOptions = {},
): ApiRuntimeConfig {
const databaseUrl = validateDatabaseUrl(input.databaseUrl);
const host = validateHost(input.host);
const port = validatePort(input.port);
const identity = createIdentityConfig(
input.identity,
options.environment === undefined ? {} : { environment: options.environment },
);
const clientAddress = createClientAddressConfig(input.clientAddress);
const environment = options.environment ?? "production";
const databaseTransport = createDatabaseTransportPolicy(input.databaseTransport, {
environment,
});
// Fails here — before `startApi` constructs a pool, Better Auth, the module runtime, or a
// listener — if a production deployment would connect without certificate-verified TLS, or if
// the URL carries TLS parameters that could override the policy.
assertProductionDatabaseTransport(databaseUrl, databaseTransport, environment);
// Validated in the same pass as every other runtime value, before `startApi` constructs a pool,
// a provider client, or a listener — so a malformed endpoint or a half-supplied credential pair
// fails startup with a sanitized code rather than surfacing later as a confusing storage error.
const storage = createStorageProviderConfig(input.storage, { environment });
// Validated in the same pass as everything else, so a malformed port, a half-supplied credential
// pair, or a production deployment that asked for plaintext SMTP fails startup with a sanitized
// code — rather than surfacing later as a confusing failure the first time something tries to
// send a password-reset email.
const mail = createMailProviderConfig(input.mail, { environment });
return Object.freeze({
clientAddress,
databaseTransport,
databaseUrl,
host,
identity,
mail,
port,
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;
}
+310
View File
@@ -0,0 +1,310 @@
import {
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_MODULES_UI_READ,
} from "@ekklesia/core/authorization";
import {
DASHBOARD_SORT_ORDER_MAX,
DASHBOARD_SORT_ORDER_MIN,
type DashboardStore,
DashboardStoreError,
isValidDashboardIdentifier,
normalizeDashboardGroupLabel,
resolveDashboardNavigation,
} from "@ekklesia/core/dashboard";
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import { describeMountableModules, type HostModuleUiDescriptor } from "./module-ui-inventory.js";
export const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
const DASHBOARD_GROUPS_PATH = `${DASHBOARD_NAVIGATION_PATH}/groups`;
const DASHBOARD_PLACEMENTS_PATH = `${DASHBOARD_NAVIGATION_PATH}/placements`;
/**
* One module as the Dashboard presents it: the module's display name, plus the generic mount
* descriptor unchanged.
*
* The descriptor is **embedded rather than flattened**, and that is the layering made visible.
* `module` is exactly `HostModuleUiDescriptor` — the host-neutral answer to "what is mountable and
* how" (ADR 0026) — and `name` is the Dashboard's presentation concern layered on top. A standalone
* host consumes the inner object and never learns that Ekklesia has folders; Ekklesia's shell
* consumes the outer one. Neither contract is warped to serve the other, and there is one
* construction of the descriptor rather than two that could drift.
*/
export interface DashboardNavigationModuleView {
readonly name: string;
readonly module: HostModuleUiDescriptor;
}
export interface DashboardNavigationGroupView {
/** The stable key, unchanged by a rename. Addresses the group in the mutation API; never displayed. */
readonly id: string;
readonly label: string;
readonly order: number;
readonly modules: readonly DashboardNavigationModuleView[];
}
export interface DashboardNavigationResponse {
readonly groups: readonly DashboardNavigationGroupView[];
/** Modules an administrator placed outside every group, and modules never configured at all — a
* newly installed module most importantly. Never empty merely because nothing has been arranged. */
readonly ungrouped: readonly DashboardNavigationModuleView[];
}
export interface CreateDashboardRoutesOptions extends AuthorizationMiddlewareOptions {
/** The same publication registry the artifact route and the generic inventory serve from: the one
* accepted, mountable module set. Navigation never rescans, never reads a manifest, and never
* discovers anything of its own. */
readonly moduleUi: ModuleUiPublicationRegistry;
readonly store: DashboardStore;
readonly trustedOrigins: ReadonlySet<string>;
}
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. A
* validation failure the route already rejects can still arrive here from a concurrent change, so
* both paths are handled rather than assumed unreachable. */
function storeFailure(c: Context, error: unknown): Response {
if (error instanceof DashboardStoreError) {
if (error.code === "DASHBOARD_GROUP_NOT_FOUND") {
return sanitizedFailure(c, 404, error.code);
}
if (error.code === "DASHBOARD_READ_FAILED" || error.code === "DASHBOARD_WRITE_FAILED") {
return sanitizedFailure(c, 503, error.code);
}
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
return sanitizedFailure(c, 503, "DASHBOARD_WRITE_FAILED");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parsedSortOrder(value: unknown): number | undefined {
if (
typeof value !== "number" ||
!Number.isSafeInteger(value) ||
value < DASHBOARD_SORT_ORDER_MIN ||
value > DASHBOARD_SORT_ORDER_MAX
) {
return undefined;
}
return value;
}
const GROUP_BODY_KEYS = new Set(["label", "order"]);
const PLACEMENT_BODY_KEYS = new Set(["groupId", "order"]);
/**
* Structured, closed validation — never a free-form configuration blob. Any key beyond the declared
* ones makes the whole body invalid rather than being silently dropped, so a caller cannot smuggle a
* field a future version might interpret, and a typo fails loudly instead of quietly doing nothing.
*/
function parseGroupBody(raw: unknown): { label: string; order: number } | undefined {
if (!isRecord(raw) || Object.keys(raw).some((key) => !GROUP_BODY_KEYS.has(key))) {
return undefined;
}
const order = parsedSortOrder(raw.order);
const label = normalizeDashboardGroupLabel(raw.label);
if (label === undefined || order === undefined) {
return undefined;
}
return { label, order };
}
function parsePlacementBody(raw: unknown): { groupId: string | null; order: number } | undefined {
if (!isRecord(raw) || Object.keys(raw).some((key) => !PLACEMENT_BODY_KEYS.has(key))) {
return undefined;
}
const order = parsedSortOrder(raw.order);
const groupId = raw.groupId;
if (order === undefined || (groupId !== null && !isValidDashboardIdentifier(groupId))) {
return undefined;
}
return { groupId, order };
}
async function readJsonBody(c: Context): Promise<unknown> {
try {
return (await c.req.json()) as unknown;
} catch {
return undefined;
}
}
/**
* The Ekklesia Dashboard's navigation: how this installation presents its mountable modules, and the
* narrow, structured API for changing that.
*
* ## Navigation is layered on the inventory, not merged into it
*
* `GET /api/v1/module-ui` (ADR 0026) answers *what may be mounted* and stays host-neutral — a
* standalone host uses it and never learns Ekklesia has folders. This route answers *how this
* Dashboard presents those modules*, and embeds each generic descriptor untouched inside a
* presentation wrapper. Keeping them separate is what stops Dashboard semantics leaking into the
* portable module-host contract.
*
* ## Grouping is presentation, and structurally cannot be anything else
*
* The resolved view is computed by `resolveDashboardNavigation`, a pure function of stored
* configuration and the current module set. It reads no manifest, no dependency, no capability, and
* no entrypoint, and it produces no relationship between two modules. Modules stay flat (ADR 0021,
* ADR 0027): two modules in one group are exactly as related as two in different groups, and moving
* one changes a single presentation row — never its artifact, its API root, its permissions, its
* dependencies, or when it starts.
*
* ## Reading versus organizing
*
* Reading requires `core.modules.ui.read`, the same permission the generic inventory requires,
* because this response *contains* that inventory and serves the same audience: any signed-in user
* needs it to see anything at all. Organizing requires `core.dashboard.navigation.manage`
* (restricted) — a platform permission, never a module's — and, like every other Core mutation, an
* exact trusted `Origin` and a bounded body before authorization work begins.
*
* Holding the manage permission grants no module authority. Every module route remains enforced
* against its own declared permission, and no arrangement of folders changes a single authorization
* decision.
*/
export function createDashboardRoutes(options: CreateDashboardRoutesOptions): Hono {
const routes = new Hono();
const middlewareOptions: AuthorizationMiddlewareOptions = {
evaluator: options.evaluator,
registry: options.registry,
runtime: options.runtime,
};
const requireRead = requirePermission(middlewareOptions, CORE_MODULES_UI_READ);
const requireManage = requirePermission(middlewareOptions, CORE_DASHBOARD_NAVIGATION_MANAGE);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
routes.get(DASHBOARD_NAVIGATION_PATH, requireRead, async (c) => {
c.header("Cache-Control", "no-store");
// Per-principal: the embedded descriptors carry permission hints evaluated for this caller.
c.header("Vary", "Origin, Cookie");
const principal = getAuthorizedPrincipal(c);
try {
const [configuration, descriptors] = await Promise.all([
options.store.readConfiguration(),
describeMountableModules(options.evaluator, principal, options.moduleUi),
]);
const view = resolveDashboardNavigation(
configuration,
descriptors.map((entry) => ({ id: entry.descriptor.id, ...entry })),
);
const present = (entry: {
readonly descriptor: HostModuleUiDescriptor;
readonly name: string;
}): DashboardNavigationModuleView => ({ module: entry.descriptor, name: entry.name });
return c.json(
{
groups: view.groups.map((group) => ({
id: group.id,
label: group.label,
modules: group.modules.map(present),
order: group.order,
})),
ungrouped: view.ungrouped.map(present),
} satisfies DashboardNavigationResponse,
200,
);
} catch (error) {
if (error instanceof DashboardStoreError) {
return storeFailure(c, error);
}
// An authorization-evaluation outage, exactly as the generic inventory reports it: a
// permission hint that could not be determined is never rendered as "not granted".
return sanitizedFailure(c, 503, "AUTHORIZATION_EVALUATION_FAILED");
}
});
routes.put(
`${DASHBOARD_GROUPS_PATH}/:groupId`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c) => {
c.header("Cache-Control", "no-store");
const groupId = c.req.param("groupId");
const body = parseGroupBody(await readJsonBody(c));
if (!body || !isValidDashboardIdentifier(groupId)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
try {
// Idempotent by design: creating and renaming are the same operation against a
// client-chosen, stable id, so a rename can never orphan the placements pointing at it.
const group = await options.store.upsertGroup({
id: groupId,
label: body.label,
sortOrder: body.order,
});
return c.json({ id: group.id, label: group.label, order: group.sortOrder }, 200);
} catch (error) {
return storeFailure(c, error);
}
},
);
routes.delete(`${DASHBOARD_GROUPS_PATH}/:groupId`, requireOrigin, requireManage, async (c) => {
c.header("Cache-Control", "no-store");
const groupId = c.req.param("groupId");
if (!isValidDashboardIdentifier(groupId)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
try {
// Modules in the group become ungrouped through the schema's `on delete set null`, so no
// module is lost and no placement is left pointing at a group that no longer exists.
await options.store.deleteGroup(groupId);
return c.body(null, 204);
} catch (error) {
return storeFailure(c, error);
}
});
routes.put(
`${DASHBOARD_PLACEMENTS_PATH}/:moduleId`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c) => {
c.header("Cache-Control", "no-store");
const moduleId = c.req.param("moduleId");
const body = parsePlacementBody(await readJsonBody(c));
if (!body || !isValidDashboardIdentifier(moduleId)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
try {
// A placement may be stored for a module that is not installed: that is how uninstalling
// and reinstalling preserves an arrangement. Nothing renders it until the module is back.
const placement = await options.store.setPlacement({
groupId: body.groupId,
moduleId,
sortOrder: body.order,
});
return c.json(
{
groupId: placement.groupId,
moduleId: placement.moduleId,
order: placement.sortOrder,
},
200,
);
} catch (error) {
return storeFailure(c, error);
}
},
);
return routes;
}
+85
View File
@@ -0,0 +1,85 @@
import type { ModuleDatabase } from "@ekklesia/contracts";
import {
type AuthorizationDrizzleDatabase,
createAuthorizationDrizzleView,
} from "@ekklesia/core/authorization";
import {
createDashboardDrizzleView,
type DashboardDrizzleDatabase,
} from "@ekklesia/core/dashboard";
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/core/devices";
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
import {
createModuleConfigurationView,
createModuleDatabaseView,
createModuleLifecycleView,
type ModuleConfigurationDrizzleDatabase,
type ModuleLifecycleDrizzleDatabase,
} from "@ekklesia/core/modules";
export interface ApiDatabaseHandle {
readonly authorization: AuthorizationDrizzleDatabase;
readonly devices: DeviceDrizzleDatabase;
readonly dashboard: DashboardDrizzleDatabase;
readonly identity: IdentityDrizzleDatabase;
readonly module: ModuleDatabase;
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
/**
* One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1`
* runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL
* cancels a stalled statement itself rather than the caller merely abandoning it; the pool's
* session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements
* travel as one simple-protocol message, so the check costs a single round trip. On any failure
* the pool discards that connection instead of returning it, so a transaction left open by a
* cancelled statement never reaches another caller. Rejects with whatever the driver raised; no
* caller may surface it.
*/
readonly ping: (statementTimeoutMs: number) => Promise<void>;
/** Closes the one shared pool exactly once, regardless of how many typed views were created
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
readonly close: () => Promise<void>;
}
function pingStatement(statementTimeoutMs: number): string {
// Interpolated rather than bound: neither SET nor a multi-statement simple query accepts
// parameters. Only a positive safe integer can ever reach the SQL text.
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) {
throw new RangeError("The ping statement timeout must be a positive integer.");
}
return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`;
}
/**
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
* authorization schemas plus the module database facade: one validated, timeout-bounded
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
* typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
* `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleConfigurationView`,
* `createModuleDatabaseView`). No domain
* owns or closes the pool in this composition — `close()`
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
* for callers that only need one domain.
*/
export function createApiDatabase(
databaseUrl: string,
options: PostgresPoolOptions = {},
): ApiDatabaseHandle {
const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
return Object.freeze({
authorization: createAuthorizationDrizzleView(pool),
close,
devices: createDeviceDrizzleView(pool),
dashboard: createDashboardDrizzleView(pool),
identity: createIdentityDrizzleView(pool),
module: createModuleDatabaseView(pool),
moduleConfiguration: createModuleConfigurationView(pool),
moduleLifecycle: createModuleLifecycleView(pool),
ping: async (statementTimeoutMs: number) => {
await pool.query(pingStatement(statementTimeoutMs));
},
});
}
+94
View File
@@ -0,0 +1,94 @@
import { createDevicePrincipal, type EkklesiaPrincipal } from "@ekklesia/contracts";
import type { DeviceStore } from "@ekklesia/core/devices";
import { DEVICE_LAST_SEEN_THROTTLE_MS } from "@ekklesia/core/devices";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { DEVICE_SESSION_COOKIE } from "./device-routes.js";
/**
* Extends principal resolution with managed devices, without touching how a *person* is resolved
* (ADR 0036).
*
* Composition rather than modification is deliberate. Core's identity runtime is the Better Auth
* boundary and knows only about human sessions; teaching it about devices would put two unrelated
* credential systems behind one implementation and make "which one answered?" an internal detail.
* Wrapping keeps Better Auth's behaviour byte-for-byte unchanged — including that an infrastructure
* failure still throws rather than degrading to anonymous — and adds exactly one fallback.
*
* **A human session always wins.** If Better Auth resolves a person, that is the answer and the
* device cookie is never even read. This matters because a browser can hold both: an administrator
* who opens a display page on their own laptop after pairing it stays themself, and no device
* cookie can dilute or replace a human identity.
*
* Because every gate in the platform — the display page, the descriptor, module HTTP routes,
* realtime — resolves principals through this one function, a device becomes a principal in exactly
* one place, and every authorization decision about it is still made by the ordinary evaluator.
*/
export interface DevicePrincipalOptions {
readonly runtime: EkklesiaIdentityRuntime;
readonly devices: DeviceStore;
readonly now?: () => Date;
}
/** RFC 6265 `cookie-string` parsing, matching how Core's own session-cookie detection reads a
* header: split on `;`, take the text before the first `=` as the name, compare whole names
* exactly. Never a substring test, so `x_ekklesia_device_session` is correctly not a match. */
function readCookie(headers: Headers, name: string): string | undefined {
const header = headers.get("cookie");
if (header === null) {
return undefined;
}
for (const pair of header.split(";")) {
const separator = pair.indexOf("=");
if (separator === -1) {
continue;
}
if (pair.slice(0, separator).trim() === name) {
const value = pair.slice(separator + 1).trim();
return value.length === 0 ? undefined : value;
}
}
return undefined;
}
export function withDevicePrincipals(options: DevicePrincipalOptions): EkklesiaIdentityRuntime {
const now = options.now ?? ((): Date => new Date());
return Object.freeze({
handleAuthRequest: (request: Request): Promise<Response> =>
options.runtime.handleAuthRequest(request),
async resolvePrincipal(headers: Headers): Promise<EkklesiaPrincipal> {
// Unchanged, including its failure behaviour: a genuine outage still throws rather than
// silently letting a device credential answer a question about a person.
const human = await options.runtime.resolvePrincipal(headers);
if (human.kind !== "anonymous") {
return human;
}
const credential = readCookie(headers, DEVICE_SESSION_COOKIE);
if (credential === undefined) {
return human;
}
const at = now();
const device = await options.devices.resolveCredential(credential, at);
if (!device) {
// A revoked, expired, or simply wrong credential is anonymous — never an error the caller
// could use to tell those three apart.
return human;
}
// Throttled here as well as in SQL: the common case must not even issue a statement, or a
// display polling realtime would turn an operational nicety into continuous write traffic.
const lastSeen = device.lastSeenAt;
if (
lastSeen === undefined ||
at.getTime() - lastSeen.getTime() >= DEVICE_LAST_SEEN_THROTTLE_MS
) {
await options.devices.touchLastSeen(device.deviceId, at).catch(() => undefined);
}
return createDevicePrincipal(device.deviceId);
},
});
}
+357
View File
@@ -0,0 +1,357 @@
import { safeParseEkklesiaDeviceId } from "@ekklesia/contracts";
import { CORE_DEVICES_MANAGE } from "@ekklesia/core/authorization";
import type { DeviceStore } from "@ekklesia/core/devices";
import { DEVICE_CREDENTIAL_TTL_MS } from "@ekklesia/core/devices";
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import { Hono as HonoApp } from "hono";
import { getCookie, setCookie } from "hono/cookie";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
/**
* Host-owned device identity transport: pairing a display, and managing what has been paired
* (ADR 0036).
*
* Both prefixes are siblings of `/api/v1/modules/...` and cannot collide with any module route.
* Nothing here is reachable by module code, and no module-facing contract mentions any of it — a
* module receives a principal and its permissions, and never learns that pairing exists.
*/
export const DEVICE_PAIRING_PREFIX = "/api/v1/device-pairing";
export const DEVICES_PREFIX = "/api/v1/devices";
/**
* The device's two cookies, deliberately named nothing like Better Auth's session cookie.
*
* Distinct names are what keep the two identity systems from being confused for one another: the
* human resolver looks only for Better Auth's cookie and the device resolver looks only for these,
* so neither can ever be satisfied by the other's credential no matter what a caller sends.
*/
export const DEVICE_PAIRING_COOKIE = "ekklesia_device_pairing";
export const DEVICE_SESSION_COOKIE = "ekklesia_device_session";
/** How often an unpaired screen should ask again. Slow enough that a waiting display is not a load
* source, fast enough that someone standing at the screen sees it come alive. */
export const DEVICE_PAIRING_POLL_INTERVAL_MS = 3_000;
export interface DeviceRoutesOptions extends AuthorizationMiddlewareOptions {
readonly devices: DeviceStore;
/** The same eligible/running publication registry the display routes use. It is the only source
* of the permission a pairing request may ask for. */
readonly moduleUi: ModuleUiPublicationRegistry;
readonly trustedOrigins: ReadonlySet<string>;
/** True when the platform is reached over https, which is what `Secure` requires to be usable at
* all. Derived from the validated identity base URL, never from a request. */
readonly secureCookies: boolean;
readonly now?: () => Date;
}
function noStore(c: Context): void {
c.header("Cache-Control", "no-store");
}
function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response {
noStore(c);
return c.json({ code }, status);
}
async function readJsonObject(c: Context): Promise<Record<string, unknown> | undefined> {
try {
const body: unknown = await c.req.json();
return typeof body === "object" && body !== null && !Array.isArray(body)
? (body as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
function readString(body: Record<string, unknown>, key: string, max: number): string | undefined {
const value = body[key];
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length === 0 || trimmed.length > max ? undefined : trimmed;
}
/**
* Writes one of the device cookies.
*
* `HttpOnly` so no script — least of all a module's display bundle, which runs in this very page —
* can read the value; `SameSite=Strict` because a display only ever talks to the host that serves
* it and nothing legitimate arrives cross-site; host-only (no `Domain`) so a sibling hostname
* cannot claim it; `Path=/` because the credential is presented to the descriptor, the module API,
* and realtime alike. `Secure` follows the deployment's own scheme — asserting it on a plaintext
* development origin would make the cookie silently unusable rather than more secure.
*/
function writeDeviceCookie(
c: Context,
options: DeviceRoutesOptions,
name: string,
value: string,
maxAgeSeconds: number,
): void {
setCookie(c, name, value, {
httpOnly: true,
maxAge: maxAgeSeconds,
path: "/",
sameSite: "Strict",
secure: options.secureCookies,
});
}
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
const routes = new HonoApp();
const now = options.now ?? ((): Date => new Date());
const requireManage = requirePermission(options, CORE_DEVICES_MANAGE);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
/**
* An unpaired display asks to be paired.
*
* The **only** thing the caller supplies is which module's display it is sitting on, and even
* that is not taken at its word: the module is looked up in the publication registry and the
* permission is read from that module's validated display declaration. A browser therefore cannot
* ask to be granted `core.devices.manage`, another module's permission, or anything else — the
* request records the permission the host derived, and approval grants exactly that.
*/
routes.post(
`${DEVICE_PAIRING_PREFIX}/requests`,
requireOrigin,
mutationBodyLimit,
async (c: Context) => {
const body = await readJsonObject(c);
const moduleId = body === undefined ? undefined : readString(body, "moduleId", 128);
if (moduleId === undefined) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const display = options.moduleUi
.listDisplays()
.find((candidate) => candidate.moduleId === moduleId);
if (!display) {
// Unknown, dashboard-only, disabled, or dependency-blocked all answer the same way, so
// pairing never becomes a way to enumerate what is installed.
return sanitizedFailure(c, 404, "NOT_FOUND");
}
try {
const at = now();
const created = await options.devices.createPairingRequest({
moduleId: display.moduleId,
now: at,
permissionId: display.displayPermission,
});
writeDeviceCookie(
c,
options,
DEVICE_PAIRING_COOKIE,
created.claimSecret,
Math.max(1, Math.ceil((created.expiresAt.getTime() - at.getTime()) / 1000)),
);
noStore(c);
// The code is returned because it has to be shown on the screen. The claim secret is not:
// it goes only into the HttpOnly cookie above, so reading the code off a television tells
// an onlooker nothing they could use.
return c.json(
{
code: created.code,
expiresAt: created.expiresAt.toISOString(),
pollIntervalMs: DEVICE_PAIRING_POLL_INTERVAL_MS,
},
201,
);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
},
);
/**
* The display asks whether it has been approved yet, and collects its credential when it has.
*
* Authority here is the claim cookie and nothing else. The pairing code is not accepted — it is
* public, printed across a screen, and a flow that issued a credential to whoever presented it
* would hand the device's identity to anyone who walked past. Consumption is single-use inside
* the store, so a replayed or concurrent claim never yields a second credential.
*/
routes.post(
`${DEVICE_PAIRING_PREFIX}/claims`,
requireOrigin,
mutationBodyLimit,
async (c: Context) => {
const claimSecret = getCookie(c, DEVICE_PAIRING_COOKIE);
if (claimSecret === undefined || claimSecret.length === 0) {
return sanitizedFailure(c, 400, "DEVICE_PAIRING_UNKNOWN");
}
try {
const at = now();
const result = await options.devices.claimPairingRequest({ claimSecret, now: at });
noStore(c);
if (result.paired) {
writeDeviceCookie(
c,
options,
DEVICE_SESSION_COOKIE,
result.paired.credential,
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
);
// The pairing cookie has done its job; leaving it would keep a spent secret on the device.
writeDeviceCookie(c, options, DEVICE_PAIRING_COOKIE, "", 0);
// Deliberately no credential, device id, or permission in the body: the browser needs to
// know only that it may now load the display, and everything it needs travels in the
// Set-Cookie header where script cannot reach it.
return c.json({ status: "paired" as const }, 200);
}
return c.json({ status: result.status }, 200);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
},
);
/**
* An administrator types the code from the screen and sees what it is asking for, before deciding.
*
* The code travels in a request body rather than a path or query so it does not land in access
* logs, browser history, or a `Referer`. Reaching this at all requires `core.devices.manage`, so
* it is not an enumeration oracle for anyone who is not already trusted to approve pairings.
*/
routes.post(
`${DEVICE_PAIRING_PREFIX}/lookups`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c: Context) => {
const body = await readJsonObject(c);
const code = body === undefined ? undefined : readString(body, "code", 16);
if (code === undefined || !/^[0-9]{6}$/u.test(code)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
try {
const found = await options.devices.findPendingByCode(code, now());
if (!found) {
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
}
noStore(c);
return c.json(
{
expiresAt: found.expiresAt.toISOString(),
moduleId: found.moduleId,
permissionId: found.permissionId,
requestedAt: found.createdAt.toISOString(),
},
200,
);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
},
);
/** Approval. The administrator names the device; the unpaired screen never names itself. */
routes.post(
`${DEVICE_PAIRING_PREFIX}/approvals`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c: Context) => {
const body = await readJsonObject(c);
const code = body === undefined ? undefined : readString(body, "code", 16);
const deviceName = body === undefined ? undefined : readString(body, "deviceName", 128);
if (code === undefined || !/^[0-9]{6}$/u.test(code) || deviceName === undefined) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const principal = getAuthorizedPrincipal(c);
if (principal.kind !== "authenticated") {
// Unreachable through `requireManage`, which only ever admits a person. Stated rather than
// assumed, so a device could never be recorded as having approved its own pairing.
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
try {
const approved = await options.devices.approvePairingRequest({
approvedBy: principal.userId,
code,
deviceName,
now: now(),
});
if (!approved) {
// Expired, already approved, already consumed, or never existed. One answer for all of
// them: which one it was is not information this endpoint owes anybody.
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
}
noStore(c);
return c.json({ moduleId: approved.moduleId, permissionId: approved.permissionId }, 200);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
},
);
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
try {
const devices = await options.devices.listDevices();
noStore(c);
return c.json(
{
devices: devices.map((device) => ({
createdAt: device.createdAt.toISOString(),
id: device.id,
name: device.name,
permissions: device.permissions,
revoked: device.revokedAt !== undefined,
...(device.lastSeenAt === undefined
? {}
: { lastSeenAt: device.lastSeenAt.toISOString() }),
...(device.revokedAt === undefined
? {}
: { revokedAt: device.revokedAt.toISOString() }),
})),
},
200,
);
} catch {
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
}
});
/**
* Revocation, which is a server-side fact rather than a request that the remote browser discard
* anything. The device's own copy of its credential keeps existing and stops working.
*/
routes.post(
`${DEVICES_PREFIX}/:deviceId/revoke`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c: Context) => {
const parsed = safeParseEkklesiaDeviceId(c.req.param("deviceId"));
if (!parsed.success) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const principal = getAuthorizedPrincipal(c);
if (principal.kind !== "authenticated") {
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
try {
await options.devices.revokeDevice({
deviceId: parsed.data,
now: now(),
revokedBy: principal.userId,
});
} catch {
return sanitizedFailure(c, 404, "NOT_FOUND");
}
noStore(c);
return c.body(null, 204);
},
);
return routes;
}
+73
View File
@@ -0,0 +1,73 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
/**
* 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
* surface package. Request input is never involved in this read and the package is never exposed as
* a static directory. Keeping text rather than a mutable `Buffer` also makes the frozen snapshot a
* genuinely immutable value that every response can reuse safely.
*/
export interface DisplayHostAssets {
readonly html: string;
readonly script: 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
* location, and machine path are intentionally not retained or surfaced. */
export class DisplayHostAssetError extends Error {
readonly code = "DISPLAY_HOST_ASSET_UNAVAILABLE" as const;
constructor() {
super("The display host browser assets are unavailable.");
this.name = "DisplayHostAssetError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* Captures one immutable, internally consistent display-host asset snapshot.
*
* The strong validator is always derived from the exact script text in the same snapshot; callers
* 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.
*/
export function createDisplayHostAssets(
html: string,
script: string,
serviceWorker: string,
): DisplayHostAssets {
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
throw new DisplayHostAssetError();
}
return Object.freeze({
html,
script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
serviceWorker,
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
});
}
/** 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. */
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
try {
// Sequential deliberately: should both files be absent, diagnostics stay deterministic and no
// rejected sibling promise is left running after this function has already failed.
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
return createDisplayHostAssets(html, script, serviceWorker);
} catch {
throw new DisplayHostAssetError();
}
}
+419
View File
@@ -0,0 +1,419 @@
import type { EkklesiaUserId, GrantId, PermissionId } from "@ekklesia/contracts";
import {
createUserActor,
isAuthenticatedPrincipal,
safeParseEkklesiaUserId,
safeParseGrantId,
safeParsePermissionId,
} from "@ekklesia/contracts";
import {
AUTHORIZATION_GRANT_LIST_MAX_LIMIT,
type AuthorizationStore,
AuthorizationStoreError,
CORE_AUTHORIZATION_GRANTS_MANAGE,
type GrantPageBoundary,
type GrantRecord,
} from "@ekklesia/core/authorization";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions {
readonly store: AuthorizationStore;
readonly trustedOrigins: ReadonlySet<string>;
/** Injected only so expiration validation is deterministic under test. Production composition
* leaves this unset and the routes read real time. */
readonly now?: () => Date;
}
const DEFAULT_LIST_LIMIT = 50;
function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
c.header("Cache-Control", "no-store");
if (error instanceof AuthorizationStoreError) {
if (error.code === "AUTHORIZATION_GRANT_NOT_FOUND") {
return c.json({ code: error.code }, 404);
}
// The one conflict the caller can act on. It deliberately reveals nothing about the grant that
// already exists — not its id, actor, scope, source reference, or expiry — only that an
// equivalent authorization is already effective for this subject.
if (error.code === "AUTHORIZATION_GRANT_ALREADY_EXISTS") {
return c.json({ code: error.code }, 409);
}
// Defense in depth: the routes below reject both of these before ever reaching the store, so
// arriving here means a request-shaped problem slipped through. It is still a client input
// failure, never an infrastructure one, and it reports exactly like any other malformed
// request rather than leaking the store's internal distinction.
if (
error.code === "AUTHORIZATION_GRANT_EXPIRATION_INVALID" ||
error.code === "AUTHORIZATION_GRANT_LIMIT_INVALID"
) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
return c.json({ code: error.code }, 503);
}
return c.json({ code: "INTERNAL_ERROR" }, 503);
}
interface ParsedCreateGrantBody {
readonly expiresAt?: Date;
readonly permission: PermissionId;
readonly subjectUserId: EkklesiaUserId;
}
const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "expiresAt"]);
/**
* The one accepted expiration representation: `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to
* the parsed `Date`'s own `toISOString()`.
*
* `new Date(value)` alone was far too permissive. It silently *normalized* impossible dates
* (`2026-02-31T00:00:00.000Z` became March 3rd) and accepted locale-shaped input
* (`"March 5, 2027"`), date-only values, offsets, and missing milliseconds — so what an operator
* typed and what the platform stored could differ without any error. The regular expression pins
* the lexical form and the round-trip comparison rejects every value the parser would otherwise
* quietly rewrite.
*/
const CANONICAL_INSTANT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
function parseCanonicalFutureInstant(value: unknown, now: Date): Date | undefined {
if (typeof value !== "string" || !CANONICAL_INSTANT_PATTERN.test(value)) {
return undefined;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) {
return undefined;
}
// Strictly future: an expiration equal to or before the acceptance clock produces a grant that
// is already dead, which is never what the caller meant.
if (parsed.getTime() <= now.getTime()) {
return undefined;
}
return parsed;
}
/**
* Accepts only `subjectUserId`, `permission`, and an optional `expiresAt`. Any other key present —
* `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`, `revokedAt`, a grant id, or
* anything claiming owner/effective-permission state — makes the whole body invalid rather than
* being silently dropped, so a caller cannot smuggle server-derived fields through a request that
* otherwise looks well-formed.
*
* **`scope` is deliberately among the rejected keys**, including when supplied as `null`, empty, or
* an otherwise valid scope string. Core's scoped-grant semantics remain intact for trusted internal
* callers, but no HTTP route middleware supplies a scope to the evaluator today, so a scope
* accepted here would produce a grant that looks meaningful, persists, appears in listings, and can
* authorize nothing at all. Accepting it would be offering a switch that is not connected to
* anything. When a server-owned resolver exists, this is the one place that changes.
*/
function parseCreateGrantBody(raw: unknown, now: Date): ParsedCreateGrantBody | undefined {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
return undefined;
}
const body = raw as Record<string, unknown>;
if (Object.keys(body).some((key) => !CREATE_GRANT_ALLOWED_KEYS.has(key))) {
return undefined;
}
const subjectUserId = safeParseEkklesiaUserId(body.subjectUserId);
if (!subjectUserId.success) {
return undefined;
}
const permission = safeParsePermissionId(body.permission);
if (!permission.success) {
return undefined;
}
let expiresAt: Date | undefined;
if (body.expiresAt !== undefined) {
expiresAt = parseCanonicalFutureInstant(body.expiresAt, now);
if (expiresAt === undefined) {
return undefined;
}
}
return Object.freeze({
...(expiresAt === undefined ? {} : { expiresAt }),
permission: permission.data,
subjectUserId: subjectUserId.data,
});
}
const CURSOR_VERSION = 1;
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
/**
* An opaque pagination *position*, not an authorization token. It carries only the subject user id,
* the boundary row's creation instant, and the boundary grant id — never a permission, scope,
* source reference, actor, credential, or secret — so there is nothing in it worth protecting.
*
* It is deliberately unsigned. Signing would imply the cursor carries authority it does not have:
* the permission check and the server-side subject filter remain authoritative on every page, and
* a forged cursor can at most name a different position within grants the caller is already
* entitled to list. The strict decode below still refuses anything that is not exactly what this
* function would emit.
*/
function encodeCursor(userId: EkklesiaUserId, boundary: GrantPageBoundary): string {
return Buffer.from(
JSON.stringify({
g: boundary.id,
t: boundary.createdAt.toISOString(),
u: userId,
v: CURSOR_VERSION,
}),
"utf8",
).toString("base64url");
}
/**
* Strict in every dimension: canonical unpadded base64url, valid UTF-8 JSON, an object with exactly
* the four expected keys, the exact version, a subject matching the *requested* one, a canonical
* `toISOString()` timestamp, a canonical `GrantId`, and — the catch-all — a re-encoding that
* reproduces the supplied string byte-for-byte, which rejects padding, trailing data, and any
* alternative encoding of the same payload. A rejected cursor reveals nothing it decoded.
*/
function decodeCursor(raw: string, userId: EkklesiaUserId): GrantPageBoundary | undefined {
if (!BASE64URL_PATTERN.test(raw)) {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
} catch {
return undefined;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return undefined;
}
const payload = parsed as Record<string, unknown>;
if (Object.keys(payload).sort().join(",") !== "g,t,u,v") {
return undefined;
}
if (payload.v !== CURSOR_VERSION || payload.u !== userId || typeof payload.t !== "string") {
return undefined;
}
const createdAt = new Date(payload.t);
if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== payload.t) {
return undefined;
}
const id = safeParseGrantId(payload.g);
if (!id.success) {
return undefined;
}
const boundary: GrantPageBoundary = Object.freeze({ createdAt, id: id.data });
if (encodeCursor(userId, boundary) !== raw) {
return undefined;
}
return boundary;
}
const LIST_ALLOWED_QUERY_KEYS = new Set(["userId", "limit", "cursor"]);
/** Canonical base-10, no sign, no leading zero, no decimal point, no whitespace, no exponent. */
const CANONICAL_POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u;
interface ParsedListQuery {
readonly after?: GrantPageBoundary;
readonly limit: number;
readonly subjectUserId: EkklesiaUserId;
}
/** Fully validated before any store or database work happens, so an invalid page request never
* becomes a query, and never becomes a way to probe which subjects or grants exist. */
function parseListQuery(url: string): ParsedListQuery | undefined {
const searchParams = new URL(url).searchParams;
for (const key of searchParams.keys()) {
if (!LIST_ALLOWED_QUERY_KEYS.has(key) || searchParams.getAll(key).length > 1) {
return undefined;
}
}
const subjectUserId = safeParseEkklesiaUserId(searchParams.get("userId"));
if (!subjectUserId.success) {
return undefined;
}
let limit = DEFAULT_LIST_LIMIT;
const rawLimit = searchParams.get("limit");
if (rawLimit !== null) {
if (!CANONICAL_POSITIVE_INTEGER_PATTERN.test(rawLimit)) {
return undefined;
}
limit = Number(rawLimit);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT) {
return undefined;
}
}
let after: GrantPageBoundary | undefined;
const rawCursor = searchParams.get("cursor");
if (rawCursor !== null) {
after = decodeCursor(rawCursor, subjectUserId.data);
if (after === undefined) {
return undefined;
}
}
return Object.freeze({
...(after === undefined ? {} : { after }),
limit,
subjectUserId: subjectUserId.data,
});
}
function serializeGrant(grant: GrantRecord) {
return {
createdAt: grant.createdAt.toISOString(),
expiresAt: grant.expiresAt ? grant.expiresAt.toISOString() : null,
id: grant.id,
permission: grant.permission,
revokedAt: grant.revokedAt ? grant.revokedAt.toISOString() : null,
scope: grant.scope ?? null,
sourceKind: grant.sourceKind,
userId: grant.userId,
};
}
/**
* The v1 explicit-grant management API: list, create, and revoke *explicit* grants only. Every
* route requires `core.authorization.grants.manage`; a platform owner passes through the
* evaluator's existing owner bypass without ever needing an explicit grant of this permission
* themselves — that bypass is exactly how a platform owner deliberately delegates grant
* administration to another account by granting them this one permission.
*
* Relationship-derived grants are never reachable through this API in either direction: creation
* always hard-codes `sourceKind: "explicit"` server-side (the request body cannot supply a
* `sourceKind` at all), and revocation is constrained to `sourceKind: "explicit"`, so attempting
* to revoke a relationship-derived grant by id behaves exactly like the grant does not exist —
* those grants remain owned by the module operation that established the relationship.
*
* Creation is unscoped-only; see `parseCreateGrantBody`.
*/
export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
const routes = new Hono();
const now = options.now ?? ((): Date => new Date());
const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
// Origin is checked first (a rejected cross-origin mutation must never have its body read), then
// the body-size limit runs before permission evaluation, so an oversized mutation never reaches
// authorization work or the store.
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
routes.get("/api/v1/grants", requireManagePermission, async (c) => {
c.header("Cache-Control", "no-store");
// Reached only after `requireManagePermission` allowed the caller, so query validation can
// never disclose a validation distinction — or the existence of any subject or grant — to an
// unauthorized caller.
const query = parseListQuery(c.req.url);
if (!query) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
try {
const page = await options.store.listGrantsForUser(query.subjectUserId, {
...(query.after === undefined ? {} : { after: query.after }),
limit: query.limit,
sourceKind: "explicit",
});
return c.json(
{
grants: page.grants.map(serializeGrant),
nextCursor:
page.nextBoundary === undefined
? null
: encodeCursor(query.subjectUserId, page.nextBoundary),
},
200,
);
} catch (error) {
return sanitizedStoreErrorResponse(c, error);
}
});
routes.post(
"/api/v1/grants",
requireOrigin,
mutationBodyLimit,
requireManagePermission,
async (c) => {
c.header("Cache-Control", "no-store");
const principal = getAuthorizedPrincipal(c);
if (!isAuthenticatedPrincipal(principal)) {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
}
let rawBody: unknown;
try {
rawBody = await c.req.json();
} catch {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
const parsed = parseCreateGrantBody(rawBody, now());
if (!parsed) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
// A well-formed PermissionId is not necessarily a *registered* one. Only the trusted registry
// (never the request) decides what may be granted — the same registry `requireManagePermission`
// already consults for the caller's own `core.authorization.grants.manage` check — so a caller
// cannot pre-create a dormant grant for an invented identifier that later becomes effective the
// moment someone registers it. Sanitized identically to any other denial: this must never reveal
// whether the identifier was malformed (400, above) or simply unregistered (403, here).
if (!options.registry.has(parsed.permission)) {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
}
try {
const grant = await options.store.createGrant({
actor: createUserActor(principal.userId),
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
permission: parsed.permission,
sourceKind: "explicit",
userId: parsed.subjectUserId,
});
return c.json({ grant: serializeGrant(grant) }, 201);
} catch (error) {
return sanitizedStoreErrorResponse(c, error);
}
},
);
routes.post(
"/api/v1/grants/:grantId/revoke",
requireOrigin,
mutationBodyLimit,
requireManagePermission,
async (c) => {
c.header("Cache-Control", "no-store");
const principal = getAuthorizedPrincipal(c);
if (!isAuthenticatedPrincipal(principal)) {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
}
// Canonical-UUID validation happens after authorization but strictly before any store or
// database call: a malformed id previously reached PostgreSQL's own UUID comparison and came
// back as an indistinguishable infrastructure failure (503) rather than a request problem.
const grantId = safeParseGrantId(c.req.param("grantId"));
if (!grantId.success) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
try {
await options.store.revokeGrant({
actor: createUserActor(principal.userId),
grantId: grantId.data satisfies GrantId,
sourceKind: "explicit",
});
return c.json({ success: true }, 200);
} catch (error) {
return sanitizedStoreErrorResponse(c, error);
}
},
);
return routes;
}
+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;
}
+375
View File
@@ -0,0 +1,375 @@
import {
type ModuleConfigurationDeclaration,
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
moduleConfigurationKeySchema,
moduleIdSchema,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import { CORE_MODULES_CONFIGURATION_MANAGE } from "@ekklesia/core/authorization";
import {
type DiscoveredModule,
type ModuleConfigurationSettings,
type ModuleConfigurationStore,
ModuleConfigurationStoreError,
resolveModuleConfigurationFor,
} from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
/**
* A **sibling** of `/api/v1/modules/`, never a child — the third application of the rule ADR 0025
* established and ADR 0028 reapplied. `config` and `configuration` are both ordinary lowercase
* static segments under `moduleRoutePathSchema`, so a module named `announcements` may legitimately
* register `PUT /config` and land on exactly `/api/v1/modules/announcements/config`. Publishing the
* platform's own configuration mutation there would put Core and an installed module in one URL
* namespace with registration order deciding which one answers — for a route that changes how
* modules behave.
*/
export const MODULE_CONFIGURATION_PATH = "/api/v1/module-configuration";
const MODULE_CONFIGURATION_MODULE_PATH = `${MODULE_CONFIGURATION_PATH}/:moduleId`;
/**
* One declared key as an operator sees it: the module's own declaration, plus what is currently
* stored, plus what the running process actually used.
*
* `value` is **absent for every `secret` key, always**. Not null, not redacted, not a placeholder —
* absent, because there is no code path that could put secret material here: Ekklesia never persists
* it, and the resolver's snapshot is never serialized. `configured` reports whether the deployment
* supplied one, which is the only thing an operator needs to know from here.
*/
export interface ModuleConfigurationKeyView {
readonly key: string;
readonly type: ModuleConfigurationDeclaration["type"];
readonly required: boolean;
readonly description?: string;
/** The declared default, for non-secret keys that have one. Part of the module's public manifest. */
readonly default?: string | number | boolean;
/** Present only for `type: "enum"`. */
readonly values?: readonly string[];
/** The stored value, when one is stored and the key is not a secret. Absent means "no value
* stored" — the default applies, or the key is unset. */
readonly value?: string;
/** For a secret: whether the deployment supplies it. For anything else: whether a value is stored.
* Never implies the value is valid — see the module's `runtime` entry for that. */
readonly configured: boolean;
}
export interface ModuleConfigurationView {
readonly moduleId: string;
readonly version: string;
readonly keys: readonly ModuleConfigurationKeyView[];
/** Stored keys this module's current manifest no longer declares. Retained rather than deleted, so
* a downgrade or a rename does not destroy an operator's value, and surfaced here so it is visible
* rather than merely tolerated. */
readonly unknownKeys: readonly string[];
/** `true` when the stored configuration no longer matches what this process resolved at boot. A
* configuration change is applied by the next restart; nothing is hot-reloaded (ADR 0029). */
readonly restartRequired: boolean;
}
export interface CreateModuleConfigurationRoutesOptions extends AuthorizationMiddlewareOptions {
/** The discovered inventory this process booted from — the source of every declaration this route
* validates against. Never re-discovered: a manifest that changed on disk since boot is not what
* the running platform is using. */
readonly discoveredModules: readonly DiscoveredModule[];
/** The stored settings this process resolved at boot, used only to compute `restartRequired` by
* comparison with what is stored now. */
readonly bootSettings: ModuleConfigurationSettings;
readonly secrets: ModuleSecretSource;
readonly store: ModuleConfigurationStore;
readonly trustedOrigins: ReadonlySet<string>;
}
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. The store's
* errors never carry a value, so nothing here can leak one either. */
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
if (error instanceof ModuleConfigurationStoreError) {
if (
error.code === "MODULE_CONFIGURATION_READ_FAILED" ||
error.code === "MODULE_CONFIGURATION_WRITE_FAILED"
) {
return sanitizedFailure(c, 503, error.code);
}
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
return sanitizedFailure(
c,
503,
fallback === "read" ? "MODULE_CONFIGURATION_READ_FAILED" : "MODULE_CONFIGURATION_WRITE_FAILED",
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const BODY_KEYS = new Set(["values"]);
/**
* The canonical text form of a submitted value, or `undefined` when it is not one this key accepts.
*
* Accepts JSON's own types — a number for `integer`, a boolean for `boolean`, a string for the rest
* — and converts to the single canonical text form the store persists and the resolver parses. It
* never accepts a string for a number (`"7"`), so exactly one spelling reaches storage and a stored
* row can never validate differently from the request that created it.
*/
function canonicalStoredValue(
declaration: ModuleConfigurationDeclaration,
submitted: unknown,
): string | undefined {
switch (declaration.type) {
case "integer": {
return typeof submitted === "number" && Number.isSafeInteger(submitted)
? String(submitted)
: undefined;
}
case "boolean": {
return typeof submitted === "boolean" ? String(submitted) : undefined;
}
case "string":
case "enum":
case "url": {
return typeof submitted === "string" &&
submitted.length <= MODULE_CONFIGURATION_VALUE_MAX_LENGTH
? submitted
: undefined;
}
case "secret": {
// Unreachable: a secret key is rejected before this is called. The branch exists so adding a
// type to the union is a compile error rather than a silent acceptance of secret material.
return undefined;
}
}
}
async function readJsonBody(c: Context): Promise<unknown> {
try {
return (await c.req.json()) as unknown;
} catch {
return undefined;
}
}
function declarationView(
declaration: ModuleConfigurationDeclaration,
storedValue: string | undefined,
secretConfigured: boolean,
): ModuleConfigurationKeyView {
const isSecret = declaration.type === "secret";
return {
// A secret is `configured` when the deployment supplies it; anything else when a value is
// stored. The two are different questions with one honest answer each.
configured: isSecret ? secretConfigured : storedValue !== undefined,
key: declaration.key,
required: declaration.required,
type: declaration.type,
...(declaration.description === undefined ? {} : { description: declaration.description }),
...(isSecret || declaration.default === undefined ? {} : { default: declaration.default }),
...(declaration.type === "enum" ? { values: declaration.values } : {}),
// The one place a stored value reaches a response — and structurally never for a secret.
...(isSecret || storedValue === undefined ? {} : { value: storedValue }),
};
}
/**
* The platform's module configuration resource: what each installed module declares it needs, what
* an operator has stored, and whether the running process is still using it.
*
* ## Secrets are absent, not filtered
*
* There is no branch here that redacts a secret, because there is nothing to redact: Ekklesia never
* persists secret material, so the store this route reads from contains none. A `secret` key's
* `value` field is omitted by construction and `configured` is computed by asking the deployment's
* secret source a yes/no question. A future code change that tried to serve a secret value would
* have to first invent somewhere to read one from.
*
* ## Changing configuration does not change the running process
*
* `PUT` replaces the stored document. It does not restart, reload, or reconfigure anything: modules
* receive a frozen snapshot resolved once at startup (ADR 0029), so the response reports
* `restartRequired` rather than pretending the change took effect.
*
* ## Closed validation against the module's own declaration
*
* A submitted key the module does not declare is rejected, as is a value of the wrong JSON type or a
* value failing the module's declared constraints. Nothing is coerced, truncated, or dropped:
* `{"slideSeconds": "7"}` is a `400`, not a silent `7`. A secret key is rejected outright — there is
* no supported way to submit secret material to this platform.
*/
export function createModuleConfigurationRoutes(
options: CreateModuleConfigurationRoutesOptions,
): Hono {
const routes = new Hono();
const requireManage = requirePermission(
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
CORE_MODULES_CONFIGURATION_MANAGE,
);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
const modulesById = new Map(
options.discoveredModules.map((module) => [module.manifest.id, module] as const),
);
const storedFor = (
settings: ModuleConfigurationSettings,
moduleId: string,
): ReadonlyMap<string, string> =>
new Map(
settings.records
.filter((record) => record.moduleId === moduleId)
.map((record) => [record.key, record.value] as const),
);
/** Two documents differ when the exact set of (key, value) pairs differs. Compared against the
* settings this process actually resolved at boot, so `restartRequired` is an observation rather
* than a guess. */
const differsFromBoot = (moduleId: string, current: ReadonlyMap<string, string>): boolean => {
const boot = storedFor(options.bootSettings, moduleId);
if (boot.size !== current.size) {
return true;
}
for (const [key, value] of current) {
if (boot.get(key) !== value) {
return true;
}
}
return false;
};
const buildView = (
module: DiscoveredModule,
settings: ModuleConfigurationSettings,
): ModuleConfigurationView => {
const stored = storedFor(settings, module.manifest.id);
const resolved = resolveModuleConfigurationFor(module.manifest, {
secrets: options.secrets,
settings,
});
return {
keys: module.manifest.configuration.map((declaration) =>
declarationView(
declaration as ModuleConfigurationDeclaration,
stored.get(declaration.key),
// Asked, never read into the response: the answer is a boolean, and the value it was
// derived from never leaves the resolver.
declaration.type === "secret"
? options.secrets.get(module.manifest.id, declaration.key) !== undefined
: false,
),
),
moduleId: module.manifest.id,
restartRequired: differsFromBoot(module.manifest.id, stored),
unknownKeys: resolved.unknownKeys,
version: module.manifest.version,
};
};
routes.get(MODULE_CONFIGURATION_MODULE_PATH, requireManage, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
const moduleId = c.req.param("moduleId");
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
}
try {
return c.json(buildView(module, await options.store.readSettings()), 200);
} catch (error) {
return storeFailure(c, error, "read");
}
});
routes.put(
MODULE_CONFIGURATION_MODULE_PATH,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c) => {
c.header("Cache-Control", "no-store");
const moduleId = c.req.param("moduleId");
const raw = await readJsonBody(c);
if (!isRecord(raw) || Object.keys(raw).some((key) => !BODY_KEYS.has(key))) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const submitted = raw.values;
if (!isRecord(submitted)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
}
const declarations = new Map(
module.manifest.configuration.map(
(declaration) =>
[declaration.key, declaration as ModuleConfigurationDeclaration] as const,
),
);
const values: { key: string; value: string }[] = [];
// `Object.keys` on a parsed JSON object never yields inherited properties, and every key is
// re-validated against the platform's one configuration-key grammar before it is used, so a
// body carrying `__proto__` or `constructor` is a plain `400` rather than anything stranger.
for (const key of Object.keys(submitted)) {
if (!moduleConfigurationKeySchema.safeParse(key).success) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const declaration = declarations.get(key);
if (!declaration) {
return sanitizedFailure(c, 400, "CONFIGURATION_KEY_UNDECLARED");
}
if (declaration.type === "secret") {
// Deliberate and permanent for this pass: Ekklesia has no encryption at rest, so there is
// no honest way to accept secret material over HTTP and store it. Secrets are supplied by
// the deployment environment (ADR 0029).
return sanitizedFailure(c, 400, "CONFIGURATION_SECRET_NOT_WRITABLE");
}
const canonical = canonicalStoredValue(declaration, submitted[key]);
if (canonical === undefined) {
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
}
values.push({ key, value: canonical });
}
// Constraint validation runs through the **same resolver the platform boots with**, over the
// exact document about to be stored, rather than a second copy of the type rules here. That
// is what guarantees the API and startup can never disagree about whether a value is valid:
// there is one implementation, and this is it.
//
// An *incomplete* document is accepted deliberately — an operator may save partial work, and
// the module is then reported `blocked · configuration_missing` until they finish. An
// *invalid* one never is: storing a value that could not survive the next boot would hand
// back a success for a change that silently breaks the module.
const candidate = resolveModuleConfigurationFor(module.manifest, {
secrets: options.secrets,
settings: {
records: values.map((entry) => ({ ...entry, moduleId: module.manifest.id })),
},
});
if (candidate.issue?.reason === "configuration_invalid") {
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
}
try {
await options.store.replace({ moduleId: module.manifest.id, values });
// Re-read rather than predict, so this response and a later GET can never disagree.
return c.json(buildView(module, await options.store.readSettings()), 200);
} catch (error) {
return storeFailure(c, error, "write");
}
},
);
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)}`;
}
+288
View File
@@ -0,0 +1,288 @@
import type { EkklesiaPrincipal } from "@ekklesia/contracts";
import type { ModuleUiPublicationRegistry, PublishableModuleDisplay } from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
} from "./authorization-middleware.js";
import { etagMatches } from "./conditional-request.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
export {
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
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 { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import {
describeModuleContributions,
grantedModulePermissionIds,
type HostModuleContributionDescriptor,
} from "./module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
/** 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. */
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
/**
* Where the display's offline service worker is served (ADR 0043).
*
* 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
* `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
* 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 primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
* management artifact into a signage route. No outer Ekklesia route or filesystem path crosses the
* boundary. */
export interface HostModuleDisplayDescriptor {
readonly id: string;
readonly version: 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 realtime: { readonly baseUrl: 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[];
}
/**
* Who the host resolved as the viewer of this display.
*
* The descriptor reports it because the display host must not ask `/api/v1/principal` — that
* endpoint answers a question about *people*, and a paired device is deliberately anonymous to it
* (ADR 0036). Reading the viewer from the response that already performed the authorization means
* the display never has to infer "no human session, so presumably a device", and the two identity
* classes never have to be told apart by elimination.
*/
export type HostDisplayViewer =
| { readonly kind: "authenticated"; readonly userId: string }
| { readonly kind: "device"; readonly deviceId: string };
export interface HostModuleDisplayResource {
readonly module: HostModuleDisplayDescriptor;
readonly viewer: HostDisplayViewer;
}
export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions {
/** The same immutable, eligible/running publication registry used for artifact bytes. */
readonly moduleUi: ModuleUiPublicationRegistry;
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
* surface-owned files before binding and supplies their frozen contents here. */
readonly assets?: DisplayHostAssets;
}
/** A composition error, detected before any display route is registered. Carries no module id,
* permission id, path, or package location. */
export class ModuleDisplayConfigurationError extends Error {
constructor() {
super("The module display host configuration is inconsistent.");
this.name = "ModuleDisplayConfigurationError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function displayFailure(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
}
/** Copies and validates injected assets before registering any route. Production's loader already
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
* bytes with a stale validator. */
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
try {
const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
if (
snapshot.scriptEtag !== input.scriptEtag ||
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
) {
throw new ModuleDisplayConfigurationError();
}
return snapshot;
} catch {
throw new ModuleDisplayConfigurationError();
}
}
function describeViewer(principal: EkklesiaPrincipal): HostDisplayViewer | undefined {
if (principal.kind === "authenticated") {
return { kind: "authenticated", userId: principal.userId };
}
if (principal.kind === "device") {
return { deviceId: principal.deviceId, kind: "device" };
}
// Unreachable behind the permission gate: the display permission is `restricted`, which no
// anonymous principal satisfies. Stated rather than assumed.
return undefined;
}
async function describeDisplay(
options: ModuleDisplayRoutesOptions,
principal: EkklesiaPrincipal,
display: PublishableModuleDisplay,
viewer: HostDisplayViewer,
): Promise<HostModuleDisplayResource> {
return {
viewer,
module: {
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
display: { url: moduleDisplayUiArtifactUrl(display) },
id: display.moduleId,
page: { url: moduleDisplayPageUrl(display.moduleId) },
permissions: await grantedModulePermissionIds(
options.evaluator,
principal,
display.permissions,
),
realtime: { baseUrl: moduleRealtimeBaseUrl(display.moduleId) },
version: display.version,
},
};
}
function mountDisplay(
app: Hono,
options: ModuleDisplayRoutesOptions & { readonly assets: DisplayHostAssets },
display: PublishableModuleDisplay,
): void {
// The permission comes from an already-validated manifest/publication record. A request selects
// neither the permission nor the artifact; it can only hit this exact pre-composed route.
const requireDisplayPermission = requirePermission(options, display.displayPermission);
/**
* The page itself is **not** permission-gated, and the descriptor below still is.
*
* An unattended screen has no identity until someone pairs it, so a gate here would mean a new
* television could only ever be told 403 — there would be nowhere for it to display a pairing
* code, and the only way to adopt one would be to sign a human into it, which is exactly the
* model this pass exists to remove (ADR 0036).
*
* Serving it costs nothing, because it carries nothing: these are the same inert, module-agnostic
* bytes for every display, with no module id, no data, and one absolute script URL that was
* already public. Authorization lives entirely in the descriptor — the page can render, and then
* learns it may show only a pairing code. What this does reveal is that a module with a display
* exists at that path, which any pairing flow must reveal to the device it is pairing.
*/
app.get(moduleDisplayPageUrl(display.moduleId), () => {
return new Response(options.assets.html, {
headers: {
"Cache-Control": "no-store",
"Content-Type": "text/html; charset=utf-8",
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
});
app.get(moduleDisplayDescriptorUrl(display.moduleId), requireDisplayPermission, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
try {
const principal = getAuthorizedPrincipal(c);
const viewer = describeViewer(principal);
if (!viewer) {
return displayFailure(c);
}
return c.json(await describeDisplay(options, principal, display, viewer), 200);
} catch {
return displayFailure(c);
}
});
}
/**
* Mounts one stable page and one descriptor route per eligible, running display declaration.
*
* There is deliberately no display inventory in this pass. A management UI may need one later,
* but a bookmarked TV needs only its version-independent page URL. Unknown, dashboard-only,
* disabled, and dependency-blocked modules have no registered route and collapse into the
* application's ordinary sanitized 404.
*
* The page is an inert, module-agnostic shell: its bytes contain no module id and its one absolute
* script URL cannot be rebased by a deep `/display/<id>` path. The browser reads the validated id
* from its outer URL, requests the permission-gated descriptor, and only then imports the exact
* versioned display artifact that the server supplied.
*/
export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutesOptions): void {
const displays = options.moduleUi.listDisplays();
if (displays.length === 0) {
return;
}
if (!options.assets) {
throw new ModuleDisplayConfigurationError();
}
const assets = snapshotAssets(options.assets);
for (const display of displays) {
if (!options.registry.has(display.displayPermission)) {
throw new ModuleDisplayConfigurationError();
}
}
// One fixed host bundle, never a directory and never a request-selected package file. Like module
// artifact bytes, fetching this platform code grants no authority; the page and descriptor are
// where identity and the module-owned display permission are enforced.
app.get(DISPLAY_HOST_SCRIPT_PATH, (c) => {
if (etagMatches(c.req.header("if-none-match"), assets.scriptEtag)) {
return new Response(null, {
headers: { "Cache-Control": "no-cache", ETag: assets.scriptEtag },
status: 304,
});
}
return new Response(assets.script, {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/javascript; charset=utf-8",
ETag: assets.scriptEtag,
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
});
/**
* 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) {
mountDisplay(app, { ...options, assets }, display);
}
}
+284
View File
@@ -0,0 +1,284 @@
import {
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleId,
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import type {
ModuleConfigurationLookup,
ModuleRealtimeRegistry,
ModuleStorageLookup,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { platformBodyLimit } from "./body-limits.js";
const MODULE_ROUTE_PREFIX = "/api/v1/modules";
const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set(["DELETE", "PATCH", "POST", "PUT"]);
const STRIPPED_CORS_RESPONSE_HEADERS = [
"access-control-allow-credentials",
"access-control-allow-headers",
"access-control-allow-methods",
"access-control-allow-origin",
"access-control-expose-headers",
"access-control-max-age",
];
export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions {
readonly trustedOrigins: ReadonlySet<string>;
/** The same frozen services object the started module runtime's `start`/`stop` hooks already
* received for this API runtime — never a fresh one built per request, and never touched for a
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
* after authorization succeeds. */
readonly services: ModuleRuntimeServices;
/**
* Per-module frozen configuration snapshots — the exact map the module runtime started from
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
* rather than two independently resolved values. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
*
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
* ever reach its own module's configuration, because that is the only entry looked up for it.
*/
readonly configurations: ModuleConfigurationLookup;
/**
* Per-module scoped object storage — the exact map the module runtime started from (ADR 0030), so
* a handler and that module's lifecycle hooks share one facade rather than two independently
* resolved ones.
*
* Keyed by module id, like `configurations` and unlike `services`: a handler can only ever reach
* its own module's storage, because that is the only entry looked up for it.
*/
readonly storages: ModuleStorageLookup;
/** The same started registry whose scoped publishers lifecycle hooks receive. */
readonly realtime: ModuleRealtimeRegistry;
}
/** Never carries the offending permission id, route path, module id, or any other configuration
* detail — a forged or inconsistent route snapshot is a startup-time composition bug, not
* something to describe in detail to whatever eventually logs it. */
export class ModuleRouteConfigurationError extends Error {
constructor() {
super("A published module HTTP route references a permission that is not registered.");
this.name = "ModuleRouteConfigurationError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* The root a module's own HTTP API is mounted at — the one place that URL shape is constructed.
*
* Exported because a browser host has to resolve `context.api.request("records")`
* (`ModuleUiApiClient`, `@ekklesia/module-ui-sdk`) to a real Ekklesia URL, and a module UI must
* never build that prefix itself: it is Ekklesia's private deployment detail, and a module that
* hardcoded it could not be hosted anywhere else (ADR 0022). The server hands the resolved base to
* the host through the module UI inventory (`./module-ui-inventory.js`) rather than letting the
* browser re-spell this template, so this prefix can change without touching either.
*
* Percent-encoded for the same reason `moduleUiArtifactUrl` is: 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 real module id (lowercase kebab-case, `moduleIdSchema`) is unaffected by the encoding.
*/
export function moduleApiBaseUrl(moduleId: string): string {
return `${MODULE_ROUTE_PREFIX}/${encodeURIComponent(moduleId)}`;
}
/** "/" is the module's own root and maps to the bare module prefix, never a required trailing
* slash; every other local path appends directly. Root and non-root therefore never collide, and
* `/api/v1/modules/<module-id>/` (the root path with a trailing slash) is never produced here. */
function moduleRoutePath(moduleId: ModuleId, routePath: string): string {
const base = moduleApiBaseUrl(moduleId);
return routePath === "/" ? base : `${base}${routePath}`;
}
function sanitizedModuleHandlerFailure(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "MODULE_HANDLER_FAILED" }, 500);
}
/**
* The one shared `Vary` parsing/canonicalization primitive: splits and trims comma-separated
* tokens and de-duplicates them case-insensitively — `Vary` lists header *names*, which are
* themselves case-insensitive, so a module's own `origin` must collapse with a later `Origin`
* rather than appear as a separate, redundant entry — while preserving first-seen casing and
* relative order. Never returns early on `*`; callers decide what a `*` token means for their own
* policy (see `mergedVaryHeader` and `canonicalizeVaryHeader` below, the two callers that build on
* this instead of re-implementing parsing/dedup independently).
*/
function parseVaryTokens(existingVary: string | null): Map<string, string> {
const canonicalByLowercase = new Map<string, string>();
for (const token of (existingVary ?? "").split(",")) {
const trimmed = token.trim();
if (trimmed.length === 0) {
continue;
}
const key = trimmed.toLowerCase();
if (!canonicalByLowercase.has(key)) {
canonicalByLowercase.set(key, trimmed);
}
}
return canonicalByLowercase;
}
/** A literal `*` (RFC 9110 §12.5.5) must never be combined with any other value; since `*` already
* means "may vary on anything," a strictly broader signal than any explicit list, its presence
* collapses the result to `*` alone rather than producing an invalid combination. */
function serializeVaryTokens(tokens: ReadonlyMap<string, string>): string {
if (tokens.has("*")) {
return "*";
}
return [...tokens.values()].join(", ");
}
/**
* Used only for a module's own response: always emits the canonical `Origin`/`Cookie` casing
* regardless of how (or whether) a module already listed them, since every module response is
* forced to vary on both (see `sanitizeModuleResponse`) — this is a deliberate product policy for
* module responses specifically, not a generic property every response through the platform's CORS
* boundary should have forced onto it (an `OPTIONS` preflight, for one, has no `Cookie` concept at
* all).
*/
export function mergedVaryHeader(existingVary: string | null): string {
const tokens = parseVaryTokens(existingVary);
if (tokens.has("*")) {
return "*";
}
tokens.set("origin", "Origin");
tokens.set("cookie", "Cookie");
return serializeVaryTokens(tokens);
}
/**
* Used at the app-level CORS boundary (`apps/api/src/app.ts`), which wraps every response —
* `OPTIONS` preflights and Core's own routes included, not only module ones — after Hono's own
* `cors()` has already appended its own `Vary: Origin`. Unlike `mergedVaryHeader`, this never
* injects a token that was not already present; it only de-duplicates and collapses `*`, since a
* generic boundary must not assume every response wants `Origin`/`Cookie` forced onto it.
*/
export function canonicalizeVaryHeader(existingVary: string): string {
return serializeVaryTokens(parseVaryTokens(existingVary));
}
/**
* The platform owns CORS and caching for every module route: strips any CORS-control header a
* handler set (defense in depth — the outer `cors()` middleware already computes the authoritative
* values for the actual response) and forces `Cache-Control: no-store` plus a `Vary` that always
* includes `Origin` and `Cookie`, since even a public-baseline handler receives an authorized
* principal and may personalize its response. A separate cache-policy contract is future work;
* this is the conservative default until then. A module's own legitimate `Vary` values (if any)
* are preserved alongside the forced ones. Never reads or buffers `response.body` — it is passed
* through to the new `Response` unread, so a streaming body is neither consumed nor blocked here.
*/
function sanitizeModuleResponse(response: Response): Response {
const headers = new Headers(response.headers);
for (const headerName of STRIPPED_CORS_RESPONSE_HEADERS) {
headers.delete(headerName);
}
headers.set("Cache-Control", "no-store");
headers.set("Vary", mergedVaryHeader(headers.get("Vary")));
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
}
/**
* Mounts `StartedModuleRuntime`'s published, immutable route snapshot into `app`, in publication
* order — never sorted or mutated, and never invoking a handler while mounting. Every route is
* protected by the exact same `requirePermission` middleware Core's own routes use, with
* `route.permission` as the only permission id ever consulted and its baseline coming exclusively
* from `options.registry`; a state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`) additionally
* requires `requireTrustedOrigin` before authorization ever runs. `GET` inherits Hono's own
* implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the
* response body afterward — there is no separate `HEAD` registration or permission path.
* `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a
* frozen context carrying exactly `config`, `moduleId`, `params`, `principal`, `realtime`,
* `services`, and `storage` — never a Hono `Context`, authorization store, raw provider, or
* deployment-wide configuration. `services.database` is the module's only reachable database
* surface, and the whole context is unreachable to anything short of a successfully authorized
* handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach
* this handler at all).
*
* Because the routes and registry both derive from the same discovered inventory
* (`prepareStartupModuleInventory`), every registered route permission should already be
* registered; this loop still checks that invariant explicitly and throws
* `ModuleRouteConfigurationError` before mounting anything further if it ever does not hold, so a
* forged or hand-built route snapshot fails app construction instead of silently mounting an
* unenforceable route. `requirePermission`'s own per-request registry lookup remains the live,
* defense-in-depth enforcement regardless.
*/
export function mountModuleHttpRoutes(
app: Hono,
routes: readonly RegisteredModuleHttpRoute[],
options: ModuleHttpRoutesOptions,
): void {
const middlewareOptions: AuthorizationMiddlewareOptions = {
evaluator: options.evaluator,
registry: options.registry,
runtime: options.runtime,
};
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
for (const route of routes) {
if (!options.registry.has(route.permission)) {
throw new ModuleRouteConfigurationError();
}
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
// all) is enforced before permission evaluation ever runs, so a request *declared* oversized
// never reaches authorization work, let alone the handler or the module database. In
// `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 finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
moduleId: route.moduleId,
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
params: Object.freeze({ ...c.req.param() }),
principal: getAuthorizedPrincipal(c),
realtime: options.realtime.publisherFor(route.moduleId),
services: options.services,
});
let response: Response;
try {
response = await route.handler(c.req.raw, context);
} catch {
return sanitizedModuleHandlerFailure(c);
}
if (!(response instanceof Response)) {
return sanitizedModuleHandlerFailure(c);
}
return sanitizeModuleResponse(response);
};
// Hono's `on()` overloads resolve by literal call arity, not by the runtime length of a spread
// array — each branch below is therefore a distinct, fixed-arity call rather than one call
// spreading a variable-length handlers array.
if (STATE_CHANGING_METHODS.has(route.method)) {
app.on(
route.method,
path,
requireOrigin,
routeBodyLimit,
requireRoutePermission,
finalHandler,
);
} else {
app.on(route.method, path, routeBodyLimit, requireRoutePermission, finalHandler);
}
}
}
+286
View File
@@ -0,0 +1,286 @@
import type {
ModuleBlockedReason,
ModuleBlockingDependency,
ModuleRuntimeState,
ModuleSecretSource,
} from "@ekklesia/contracts";
import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization";
import {
type ModuleConfigurationStore,
type ModuleLifecycleSnapshot,
type ModuleStorageAvailability,
type ModuleLifecycleStore,
ModuleLifecycleStoreError,
type ModuleLifecycleView,
resolveModuleLifecycleView,
} from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
/**
* A **sibling** of `/api/v1/modules/`, never a child of it — the same rule ADR 0025 established for
* `/api/v1/module-ui`, and for the same concrete reason. Every module HTTP route is mounted under
* `/api/v1/modules/<module-id>/<local path>`, and `moduleRoutePathSchema` accepts `enabled` as an
* ordinary static segment, so a module named `announcements` registering `PUT /enabled` would land
* on exactly `/api/v1/modules/announcements/enabled`. Publishing the platform's own lifecycle
* mutation there would put Core and an installed module in one URL namespace with registration
* order deciding which one answers — for a route that changes whether modules run. A sibling prefix
* cannot collide.
*/
export const MODULE_LIFECYCLE_PATH = "/api/v1/module-lifecycle";
const MODULE_LIFECYCLE_ENABLED_PATH = `${MODULE_LIFECYCLE_PATH}/:moduleId/enabled`;
/**
* One module as an operator sees it. Two clearly separated axes, deliberately never merged into a
* single status word.
*
* `configured` is the decision that survives restarts. `runtime` is what *this process* did. They
* can legitimately disagree — Ekklesia does not hot-load or hot-unload modules — and
* `restartRequired` names that disagreement instead of hiding it behind a status that would be a
* lie in one direction or the other.
*
* Nothing here carries a filesystem path, a package name, a manifest path, a directory, an
* entrypoint, a caught error, or a stack. The only identifiers are module ids, capability ids, and
* a fixed reason code from a closed set — all of them values the platform itself produced and
* validated.
*/
export interface ModuleLifecycleModuleView {
readonly id: string;
readonly version: string;
readonly configured: {
readonly enabled: boolean;
/** `false` means nobody has decided and the platform default (enabled) applies. */
readonly explicit: boolean;
};
readonly runtime: {
readonly state: ModuleRuntimeState;
readonly reason?: ModuleBlockedReason;
/** Present for a dependency reason. */
readonly dependency?: ModuleBlockingDependency;
/** Present for a configuration reason (ADR 0029): the offending key, never its value. A key is
* declared in a public manifest; a value may be a credential or arbitrary operator text. */
readonly configurationKey?: string;
/** Present for a storage reason (ADR 0030): the logical bucket the module declared, never an
* endpoint, a credential, or the physical bucket the host maps it to. */
readonly storageBucketId?: string;
};
readonly restartRequired: boolean;
}
export interface ModuleLifecycleResponse {
readonly modules: readonly ModuleLifecycleModuleView[];
}
export interface CreateModuleLifecycleRoutesOptions extends AuthorizationMiddlewareOptions {
/** The boot snapshot: the discovered inventory this process started from, the eligibility it
* resolved, and the modules the runtime reports it actually started. Captured once at startup and
* never recomputed — this endpoint describes the platform that is running, not one that would run
* if it were restarted right now. */
readonly snapshot: ModuleLifecycleSnapshot;
readonly store: ModuleLifecycleStore;
/**
* How to re-read the *other* axis that decides eligibility (ADR 0029). Since a module's own
* configuration can block it, `restartRequired` must be computed against live configuration as
* well as live enablement — otherwise correcting a missing required value would report no pending
* restart for a module the next boot really will start.
*/
readonly configuration: {
readonly store: ModuleConfigurationStore;
readonly secrets: ModuleSecretSource;
};
/** The other axis that decides eligibility (ADR 0030). A boolean, never a probe: whether storage
* is *configured* decides eligibility, while whether it is *reachable right now* never does. */
readonly storage: ModuleStorageAvailability;
readonly trustedOrigins: ReadonlySet<string>;
}
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary, exactly as
* `dashboard-routes.ts` does. */
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
if (error instanceof ModuleLifecycleStoreError) {
if (
error.code === "MODULE_LIFECYCLE_READ_FAILED" ||
error.code === "MODULE_LIFECYCLE_WRITE_FAILED"
) {
return sanitizedFailure(c, 503, error.code);
}
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
return sanitizedFailure(
c,
503,
fallback === "read" ? "MODULE_LIFECYCLE_READ_FAILED" : "MODULE_LIFECYCLE_WRITE_FAILED",
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const ENABLED_BODY_KEYS = new Set(["enabled"]);
/**
* Structured, closed validation — never a configuration blob. Any key beyond `enabled` makes the
* whole body invalid rather than being silently dropped, so a caller cannot smuggle a field a
* future version might interpret, and a typo fails loudly instead of quietly doing nothing. Mirrors
* `parseGroupBody`/`parsePlacementBody` (`./dashboard-routes.js`) exactly.
*/
function parseEnabledBody(raw: unknown): { enabled: boolean } | undefined {
if (!isRecord(raw) || Object.keys(raw).some((key) => !ENABLED_BODY_KEYS.has(key))) {
return undefined;
}
return typeof raw.enabled === "boolean" ? { enabled: raw.enabled } : undefined;
}
async function readJsonBody(c: Context): Promise<unknown> {
try {
return (await c.req.json()) as unknown;
} catch {
return undefined;
}
}
function present(view: ModuleLifecycleView): ModuleLifecycleModuleView {
return {
configured: { enabled: view.configured.enabled, explicit: view.configured.explicit },
id: view.moduleId,
restartRequired: view.restartRequired,
runtime: {
state: view.runtime.state,
...(view.runtime.reason === undefined ? {} : { reason: view.runtime.reason }),
...(view.runtime.dependency === undefined ? {} : { dependency: view.runtime.dependency }),
...(view.runtime.configurationKey === undefined
? {}
: { configurationKey: view.runtime.configurationKey }),
...(view.runtime.storageBucketId === undefined
? {}
: { storageBucketId: view.runtime.storageBucketId }),
},
version: view.version,
};
}
/**
* The platform's operational lifecycle resource: which installed modules are enabled, what this
* process actually did with each of them, and the narrow, structured API for changing that.
*
* ## Enablement is host configuration, and structurally cannot be anything else
*
* A module neither declares nor reads its own enabled state. Nothing in a manifest, a capability, a
* permission declaration, or a lifecycle hook can influence a value stored here, and a module is
* never told it is enabled — its host simply loads and starts it, or does not. That is what keeps
* the same module implementation reusable under a future standalone host, whose loading decisions
* are its own (ADR 0028).
*
* ## Changing configuration does not change the running process
*
* `PUT` writes a decision. It does not start, stop, load, unload, or reconfigure anything: there is
* no hot module installation or removal in this platform (ADR 0015, unchanged). The response
* therefore reports the new configuration *and* the unchanged runtime observation *and*
* `restartRequired`, rather than pretending the module stopped. Hot-unloading a module to make a
* button feel immediate would trade a real guarantee — that a module's ESM evaluation happens
* exactly once, and can never be rolled back — for a cosmetic one.
*
* ## Only a module this process can see may be configured
*
* `PUT` against an id that is not in the boot inventory is a `404`. Unlike an inert Dashboard
* placement, an enablement record is operational configuration, and accepting one for an arbitrary
* identifier would make this an unbounded write surface for a delegable permission. *Existing*
* records for modules that are no longer deployed are still retained and simply ignored, so
* redeploying a module restores the decision (see `resolveModuleEligibility`).
*
* ## Reading is administrative too
*
* Both verbs require `core.modules.lifecycle.manage` (restricted). This resource is the only place
* a disabled module, a blocked module, and the required-dependency edges between them are visible;
* `core.modules.ui.read` deliberately covers only what is mountable. Mutations additionally require
* an exact trusted `Origin` and a bounded body before authorization work begins, exactly like every
* other Core mutation.
*/
export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutesOptions): Hono {
const routes = new Hono();
const requireManage = requirePermission(
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
CORE_MODULES_LIFECYCLE_MANAGE,
);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
const installedModuleIds = new Set(
options.snapshot.discoveredModules.map((module) => module.manifest.id),
);
routes.get(MODULE_LIFECYCLE_PATH, requireManage, async (c) => {
c.header("Cache-Control", "no-store");
// Not per-principal in content, but it is cookie-authorized, so it varies for the same reason
// every other authorized Core read does.
c.header("Vary", "Origin, Cookie");
try {
const [configuration, settings] = await Promise.all([
options.store.readConfiguration(),
options.configuration.store.readSettings(),
]);
const modules = resolveModuleLifecycleView(
options.snapshot,
configuration,
{ secrets: options.configuration.secrets, settings },
options.storage,
).map(present);
return c.json({ modules } satisfies ModuleLifecycleResponse, 200);
} catch (error) {
return storeFailure(c, error, "read");
}
});
routes.put(
MODULE_LIFECYCLE_ENABLED_PATH,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c) => {
c.header("Cache-Control", "no-store");
const moduleId = c.req.param("moduleId");
const body = parseEnabledBody(await readJsonBody(c));
if (!body) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
if (!installedModuleIds.has(moduleId)) {
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
}
try {
await options.store.setEnabled({ enabled: body.enabled, moduleId });
// Re-read rather than predict: the response describes the configuration that is actually
// stored now, resolved against the same boot snapshot the collection read uses, so the two
// endpoints can never disagree about `restartRequired`.
const [configuration, settings] = await Promise.all([
options.store.readConfiguration(),
options.configuration.store.readSettings(),
]);
const view = resolveModuleLifecycleView(
options.snapshot,
configuration,
{ secrets: options.configuration.secrets, settings },
options.storage,
).find((entry) => entry.moduleId === moduleId);
if (!view) {
return sanitizedFailure(c, 503, "MODULE_LIFECYCLE_READ_FAILED");
}
return c.json(present(view), 200);
} catch (error) {
return storeFailure(c, error, "write");
}
},
);
return routes;
}
+252
View File
@@ -0,0 +1,252 @@
import { ModuleRealtimeError, type ModuleId, type PermissionId } from "@ekklesia/contracts";
import type {
ModuleRealtimePublication,
ModuleRealtimeRegistry,
RegisteredModuleRealtimeChannel,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
requirePermission,
} from "./authorization-middleware.js";
const MODULE_REALTIME_PREFIX = "/api/v1/module-realtime";
/** Host-owned keepalive interval. Comments keep idle HTTP streams alive without entering module code,
* consuming a sequence, or becoming a browser-visible state update. */
export const MODULE_REALTIME_KEEPALIVE_MS = 15_000;
/** Forces a fresh cookie/permission evaluation periodically rather than letting an authorization
* decision live for an unbounded stream lifetime. EventSource reconnects after the clean EOF. */
export const MODULE_REALTIME_MAX_CONNECTION_AGE_MS = 5 * 60_000;
export interface ModuleRealtimeTransportTiming {
/** Test-only narrowing of the production keepalive. Production composition never sets this. */
readonly keepaliveMs?: number;
/** Test-only narrowing of the production reauthorization interval. */
readonly maxConnectionAgeMs?: number;
}
export interface ModuleRealtimeRoutesOptions
extends AuthorizationMiddlewareOptions,
ModuleRealtimeTransportTiming {
readonly realtime: ModuleRealtimeRegistry;
}
/** The server is the sole author of this deployment-private base. Module UI receives it through the
* host descriptor and never reconstructs the platform prefix or supplies its own module id. */
export function moduleRealtimeBaseUrl(moduleId: string): string {
return `${MODULE_REALTIME_PREFIX}/${encodeURIComponent(moduleId)}`;
}
function moduleRealtimeChannelUrl(moduleId: ModuleId, channel: string): string {
return `${moduleRealtimeBaseUrl(moduleId)}/${encodeURIComponent(channel)}`;
}
/** A forged runtime snapshot that could not be permission-gated is a composition failure. Carries no
* module/channel/permission value and is thrown before any route from that snapshot is usable. */
export class ModuleRealtimeConfigurationError extends Error {
constructor() {
super("A published module realtime channel references a permission that is not registered.");
this.name = "ModuleRealtimeConfigurationError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function realtimeEnvelope(publication: ModuleRealtimePublication): string {
// `serializedPayload` was validated and serialized exactly once by Core. Concatenating it as the
// JSON value preserves every opaque string — including signed object-delivery URLs — byte for byte.
return `{"sequence":${publication.sequence},"payload":${publication.serializedPayload}}`;
}
function realtimeEvent(
kind: "snapshot" | "update",
publication: ModuleRealtimePublication,
): string {
// Core's compact JSON serialization contains no literal CR/LF bytes inside strings, so one `data`
// line is sufficient. Deliberately no SSE `id`: this transport does not implement Last-Event-ID
// replay, and a process-local sequence must not be mistaken for a durable cursor.
return `event: ${kind}\ndata: ${realtimeEnvelope(publication)}\n\n`;
}
function sanitizedRealtimeFailure(c: Context, error: unknown): Response {
c.header("Cache-Control", "no-store");
const code =
error instanceof ModuleRealtimeError && error.code === "REALTIME_SUBSCRIBER_LIMIT"
? "REALTIME_SUBSCRIBER_LIMIT"
: "REALTIME_UNAVAILABLE";
return c.json({ code }, 503);
}
interface CancelableDelay {
readonly promise: Promise<"keepalive">;
cancel(): void;
}
function keepaliveDelay(milliseconds: number): CancelableDelay {
let timer: ReturnType<typeof setTimeout> | undefined;
const promise = new Promise<"keepalive">((resolve) => {
timer = setTimeout(() => resolve("keepalive"), milliseconds);
});
return {
cancel: () => clearTimeout(timer),
promise,
};
}
function createRealtimeStream(
definition: RegisteredModuleRealtimeChannel,
registry: ModuleRealtimeRegistry,
keepaliveMs: number,
maxConnectionAgeMs: number,
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const subscription = registry.subscribe(definition.moduleId, definition.channel);
let snapshotPending = true;
let closed = false;
let pendingRead: Promise<ModuleRealtimePublication | undefined> | undefined;
let activeDelay: CancelableDelay | undefined;
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
const close = (): void => {
if (closed) {
return;
}
closed = true;
activeDelay?.cancel();
activeDelay = undefined;
clearTimeout(expiryTimer);
expiryTimer = undefined;
subscription.close();
};
return new ReadableStream<Uint8Array>({
start(controller) {
expiryTimer = setTimeout(() => {
close();
try {
controller.close();
} catch {
// A simultaneous browser cancellation or completed pull already closed the controller.
}
}, maxConnectionAgeMs);
},
cancel: () => close(),
async pull(controller) {
if (closed) {
return;
}
try {
if (snapshotPending) {
snapshotPending = false;
controller.enqueue(encoder.encode(realtimeEvent("snapshot", subscription.snapshot)));
return;
}
// Only one Core read is ever outstanding. If a keepalive wins the race, the same read is
// retained for the next pull instead of allocating a second listener. Core itself holds only
// one newest pending publication, and this stream asks for work only when the browser-facing
// ReadableStream has demand, so a slow client cannot create an array at either layer.
pendingRead ??= subscription.read();
activeDelay = keepaliveDelay(keepaliveMs);
const result = await Promise.race([pendingRead, activeDelay.promise]);
activeDelay.cancel();
activeDelay = undefined;
if (result === "keepalive") {
controller.enqueue(encoder.encode(": keepalive\n\n"));
return;
}
pendingRead = undefined;
if (result === undefined) {
close();
controller.close();
return;
}
controller.enqueue(encoder.encode(realtimeEvent("update", result)));
} catch {
close();
try {
controller.close();
} catch {
// The browser may have canceled between the awaited read/timer and this close. Cancellation
// already performed the same idempotent subscription cleanup.
}
}
},
});
}
function mountChannel(
app: Hono,
definition: RegisteredModuleRealtimeChannel,
options: ModuleRealtimeRoutesOptions,
keepaliveMs: number,
maxConnectionAgeMs: number,
): void {
const requireChannelPermission = requirePermission(
options,
definition.permission as PermissionId,
);
app.get(
moduleRealtimeChannelUrl(definition.moduleId, definition.channel),
requireChannelPermission,
(c) => {
// Hono deliberately lets a registered GET handler answer HEAD. That is useful for finite
// resources, but unsafe for an infinite stream: the adapter discards the response body without
// necessarily canceling it, which could retain a Core subscriber forever. Refuse HEAD before
// allocating anything. Authentication/authorization has still run through the same middleware.
if (c.req.method !== "GET") {
return new Response(null, {
headers: { Allow: "GET", "Cache-Control": "no-store" },
status: 405,
});
}
let stream: ReadableStream<Uint8Array>;
try {
stream = createRealtimeStream(
definition,
options.realtime,
keepaliveMs,
maxConnectionAgeMs,
);
} catch (error) {
return sanitizedRealtimeFailure(c, error);
}
return new Response(stream, {
headers: {
"Cache-Control": "no-store",
Connection: "keep-alive",
"Content-Type": "text/event-stream; charset=utf-8",
Vary: "Origin, Cookie",
// Honored by nginx and harmless elsewhere. Other proxies must likewise disable buffering for
// this path; no proxy-specific behavior enters the module-facing contract.
"X-Accel-Buffering": "no",
},
status: 200,
});
},
);
}
/** Mounts one exact, immutable, permission-gated GET route per successfully started module channel.
* There is no dynamic module/channel lookup route and no request-controlled permission selection. */
export function mountModuleRealtimeRoutes(app: Hono, options: ModuleRealtimeRoutesOptions): void {
const keepaliveMs = options.keepaliveMs ?? MODULE_REALTIME_KEEPALIVE_MS;
const maxConnectionAgeMs = options.maxConnectionAgeMs ?? MODULE_REALTIME_MAX_CONNECTION_AGE_MS;
if (!Number.isSafeInteger(keepaliveMs) || keepaliveMs <= 0) {
throw new TypeError("The module realtime keepalive interval is invalid.");
}
if (!Number.isSafeInteger(maxConnectionAgeMs) || maxConnectionAgeMs <= 0) {
throw new TypeError("The module realtime maximum connection age is invalid.");
}
for (const definition of options.realtime.channels) {
if (!options.registry.has(definition.permission)) {
throw new ModuleRealtimeConfigurationError();
}
}
for (const definition of options.realtime.channels) {
mountChannel(app, definition, options, keepaliveMs, maxConnectionAgeMs);
}
}
+296
View File
@@ -0,0 +1,296 @@
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
import type {
ModuleUiPublicationRegistry,
PublishableModuleContribution,
PublishableModuleUi,
} from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
} from "./authorization-middleware.js";
import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleDisplayPageUrl } from "./module-display-path.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-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
* mount, and `/api/v1/module-ui/<module-id>/<version>` (ADR 0025) serves one artifact's bytes. One
* prefix, one resource, two representations.
*/
export const MODULE_UI_INVENTORY_PATH = MODULE_UI_ASSET_PREFIX;
/**
* Exactly what a browser host needs to mount one module's UI, and deliberately nothing else.
*
* No filesystem path, package root, package name, server/schema/migrations entrypoint, manifest
* version, capability, dependency, or storage bucket appears here. Those are server-side facts a
* browser has no use for, and several of them are precisely what ADR 0025's publication route
* exists to keep off the wire.
*
* There is also no `name`, `icon`, `description`, `category`, `folder`, or `order`. Those are
* *navigation* concerns, and ADR 0021 established that Dashboard grouping is presentation
* configuration an administrator owns rather than something a module declares. A field added here
* now would quietly become module-owned navigation metadata before that design exists; the
* temporary selector renders the module id instead.
*/
export interface HostModuleUiDescriptor {
readonly id: string;
readonly version: string;
/** Where the browser fetches this module's compiled UI artifact. Produced by the server's own
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
* routing can change without touching a single line of host or module code. */
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
* 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
* module nor the browser host should be the one spelling it. */
readonly api: { readonly baseUrl: string };
/** Server-produced root for this module's host-neutral browser realtime facade. The browser host,
* not module UI, resolves and appends a validated local channel. */
readonly realtime: { readonly baseUrl: string };
/**
* The subset of **this module's own declared permissions** that the current principal holds,
* evaluated server-side by Core's one authorization evaluator.
*
* Scoped to the module deliberately: a host has no business handing one module the platform's
* whole authorization state, and the module's manifest already declares exactly which permissions
* its UI can meaningfully ask about. Granted ids only — a permission the principal lacks is simply
* absent, so this never enumerates a module's full permission surface to someone who holds none of
* it.
*
* **These are presentation hints, not authority** (ADR 0022). They exist so a UI can hide a
* control instead of offering one that would fail. Every module route remains enforced by
* `requirePermission` against the composed platform registry, and that enforcement neither reads
* nor trusts anything in this array.
*/
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 {
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 {
/** The same publication registry the artifact route serves from — the single accepted/publishable
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
* no discovery, reads no manifest, and touches no filesystem; a module absent from this registry
* is absent from the inventory, by construction rather than by filtering. Named `moduleUi` rather
* than `registry` because `AuthorizationMiddlewareOptions.registry` is already the *permission*
* registry, and one route needs both. */
readonly moduleUi: ModuleUiPublicationRegistry;
}
/**
* Evaluates one module's declared permissions against the caller, returning only the granted ids.
*
* Uses Core's one evaluator, so baselines, the platform-owner bypass, grant expiry, and
* deny-by-default all behave exactly as they do on a real request — there is no second,
* browser-only interpretation of what a permission means. An infrastructure failure propagates as
* `AuthorizationEvaluationError` rather than being folded into "not granted", because a hint that
* silently read "denied" during a database outage would blank the whole UI and look like a
* permissions problem.
*/
/** Shared by the primary-UI inventory and the singular display descriptor. Both provide the same
* presentation-only subset of a module's own declarations and use Core's one evaluator. */
export async function grantedModulePermissionIds(
evaluator: AuthorizationMiddlewareOptions["evaluator"],
principal: EkklesiaPrincipal,
declarations: readonly PermissionDeclaration[],
): Promise<readonly string[]> {
const decisions = await Promise.all(
declarations.map(async (declaration) => ({
allowed: (
await evaluator.authorize({
baseline: declaration.baseline,
permission: declaration.id,
principal,
})
).allowed,
id: String(declaration.id),
})),
);
return decisions.filter((decision) => decision.allowed).map((decision) => decision.id);
}
async function describeModule(
evaluator: AuthorizationMiddlewareOptions["evaluator"],
principal: EkklesiaPrincipal,
published: PublishableModuleUi,
registry: ModuleUiPublicationRegistry,
): Promise<HostModuleUiDescriptor> {
const display = registry.resolveDisplay(published.moduleId, published.version);
return {
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
contributions: describeModuleContributions(registry, published.moduleId),
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
id: published.moduleId,
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
ui: { url: moduleUiArtifactUrl(published) },
version: published.version,
};
}
/** One mountable module, described once: the host-neutral mount descriptor plus the module's own
* display name, kept beside it rather than inside it. */
export interface MountableModuleDescription {
readonly descriptor: HostModuleUiDescriptor;
/** `manifest.name`. Presentation *identity* the module owns — never its placement, which the
* Dashboard owns (ADR 0027). Deliberately absent from `HostModuleUiDescriptor`, so the
* host-neutral contract a standalone host consumes stays free of presentation concerns. */
readonly name: string;
}
/**
* The one composition of the mountable-module set, shared by the generic inventory route and the
* Dashboard navigation route (`./dashboard-routes.js`).
*
* Both derive from the same publication registry and the same evaluator, so the two endpoints can
* never disagree about what exists, what its URLs are, or what the caller may do inside it. Sharing
* this function rather than duplicating the mapping is what makes that a structural property instead
* of a convention.
*/
export async function describeMountableModules(
evaluator: AuthorizationMiddlewareOptions["evaluator"],
principal: EkklesiaPrincipal,
registry: ModuleUiPublicationRegistry,
): Promise<readonly MountableModuleDescription[]> {
return Promise.all(
registry.list().map(async (published) => ({
descriptor: await describeModule(evaluator, principal, published, registry),
name: published.name,
})),
);
}
/** Mirrors `requirePermission`'s own infrastructure-failure result exactly: a permission hint that
* could not be determined is never reported as "not granted". Carries no module id, permission id,
* or caught error. */
function sanitizedInventoryFailure(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
}
/**
* The browser host's one bootstrap query: which module UIs may I mount, where do I fetch each one,
* where does each one's API live, and what may this viewer do inside it.
*
* ## It is not a module management API
*
* It lists mountable UIs and nothing else. There is no create, update, enable, disable, install, or
* configure; no module that declares no UI; and no field beyond what constructing a
* `ModuleUiHostContext` requires. Ekklesia has no enable/disable state to report —
* `ModuleLifecycleStage` is vocabulary with no runtime state machine behind it — and inventing one
* here would be inventing a lifecycle rather than reporting one.
*
* ## The module set comes from the publication registry, never a rescan
*
* `moduleUi.list()` is the exact accepted set ADR 0025 publishes artifacts from: modules that
* survived discovery and declare `entrypoints.ui`, captured after the module runtime started. This
* route never scans a directory, reads a manifest, or reconstructs a module from `package.json`, so
* the inventory and the artifact route can never disagree about what exists. Order is the
* registry's own discovery order, which is deterministic (topologically sorted, then by id).
*
* ## Authenticated, deliberately, unlike the artifact route
*
* ADR 0025 made the artifact bytes anonymous — fetching compiled UI code confers no authority — but
* was careful that the route never became an enumeration oracle: unknown module, no UI, and wrong
* version collapse into one indistinguishable `404`, so a caller must already know both halves of an
* identity to learn anything. **This endpoint is that enumeration.** Serving it anonymously would
* hand over exactly what that route withholds, so it requires `core.modules.ui.read` (baseline
* `authenticated`): any signed-in user of the installation, and nobody else.
*
* Enforcement goes through the same `requirePermission` middleware every other protected route uses
* — no second browser-auth mechanism — which also resolves the principal exactly once and keeps a
* genuine denial (`403`) distinct from an infrastructure failure (`503`).
*
* The response is per-principal (its `permissions` depend on who is asking), so it is `no-store` and
* varies on `Origin` and `Cookie`, exactly like `/api/v1/principal`.
*/
export function createModuleUiInventoryRoutes(options: CreateModuleUiInventoryRoutesOptions): Hono {
const routes = new Hono();
const requireInventoryPermission = requirePermission(
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
CORE_MODULES_UI_READ,
);
routes.get(MODULE_UI_INVENTORY_PATH, requireInventoryPermission, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
// Resolved and already authorized by the middleware above; never resolved a second time here.
// The `authenticated` baseline guarantees this is an authenticated principal.
const principal = getAuthorizedPrincipal(c);
try {
const described = await describeMountableModules(
options.evaluator,
principal,
options.moduleUi,
);
// Only the host-neutral descriptor reaches this response: the display name stays out of the
// portable contract and is carried by the Dashboard navigation view instead.
const modules = described.map((entry) => entry.descriptor);
return c.json({ modules } satisfies HostModuleUiInventory, 200);
} catch {
return sanitizedInventoryFailure(c);
}
});
return routes;
}
+289
View File
@@ -0,0 +1,289 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
/**
* The platform-owned URL space for published module UI artifacts.
*
* Deliberately **not** nested under `/api/v1/modules/<module-id>`, and that is a security decision
* rather than a stylistic one. A module may declare any route path matching Core's own grammar
* (`moduleRoutePathSchema`, `@ekklesia/contracts`), and `ui` is a perfectly legal lowercase static
* segment — so a module registering `GET /ui/:version` would be mounted at exactly
* `/api/v1/modules/<module-id>/ui/<version>`. Publishing there would put the platform and installed
* modules in the same URL namespace, with Hono's registration order silently deciding which one
* answers. A sibling prefix can never collide: every module route begins `/api/v1/modules/`, and
* nothing the platform serves here does.
*
* It stays under `/api/v1/*` so it inherits the application's one CORS policy (exact reflected
* trusted origins, never a wildcard) — which a cross-origin browser `import()` of this URL requires
* — instead of introducing a second, separately-configured public URL space.
*/
export const MODULE_UI_ASSET_PREFIX = "/api/v1/module-ui";
/**
* Revalidate-always, with a strong `ETag` doing the actual work.
*
* The URL carries module id and exact installed version, which is *identity*, not *immutability*.
* Ekklesia has no installer, no content addressing, and no integrity verification, so nothing stops
* an operator from rebuilding version `1.2.3` with different bytes and restarting — the ordinary
* inner-loop of developing a module. `immutable` (or any non-zero `max-age`) would therefore be a
* promise the platform cannot keep, and a browser that cached a superseded artifact under a reused
* version would have no way to recover.
*
* `no-cache` is not `no-store`: the response is still cached, it just has to be revalidated. With
* the `ETag` below, an unchanged artifact costs one conditional request and a `304`, and a changed
* one is picked up immediately. A version bump changes the URL and never collides at all. When a
* real installer can guarantee that an installed version's bytes never change, this is the single
* constant that would move to immutable caching.
*/
export const MODULE_UI_CACHE_CONTROL = "no-cache";
/** Modern ESM is served as `text/javascript`, the one JavaScript type the WHATWG MIME sniffing
* standard designates as canonical; `application/javascript` is a legacy alias. A browser refuses
* to evaluate an ES module served as anything else, so this is a correctness requirement, not a
* courtesy. */
export const MODULE_UI_CONTENT_TYPE = "text/javascript; charset=utf-8";
export interface ModuleUiIdentity {
readonly moduleId: string;
readonly version: string;
}
/**
* The one canonical way to turn an installed module's identity into the browser URL its UI artifact
* is published at. Every producer of that URL — this package's own route registration, a future
* module-metadata endpoint, and a future Dashboard host — goes through here, so the URL shape is
* defined once instead of being re-spelled as a template string in each of them.
*
* The result is a **root-relative browser URL, never a filesystem path** — the distinction ADR 0024
* built the loader around. A host on the API's own origin passes it to `loadModuleUi` unchanged; a
* host on a different origin resolves it against the same API base URL it already needs for
* `ModuleUiApiClient` (`new URL(moduleUiArtifactUrl(identity), apiBaseUrl)`). It is deliberately not
* absolute: the API's public origin is deployment configuration, and baking one in here would break
* both split-origin deployments and reverse proxies.
*
* This function accepts **identity only**. There is no parameter for a filename, a relative path,
* or any other artifact selector, so no caller — however careless — can use it to reach a second
* file in a module package. Both components are percent-encoded, so even a value that never came
* from a validated manifest can only ever produce one extra path segment's worth of text, never a
* new segment, a traversal, a query, or a fragment. A value that needed encoding will simply fail
* to resolve against the publication registry.
*/
export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string {
return `${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent(identity.moduleId)}/${encodeURIComponent(
identity.version,
)}`;
}
/**
* Canonical browser URL for a module's independently declared display artifact.
*
* The literal trailing segment is host-owned, not a caller-selected role. Keeping this as a
* separate helper and an exact route means neither request input nor a generic role parameter can
* select a neighbouring artifact. A future standalone host may expose a different outer display
* page while consuming this same published module artifact internally.
*/
export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
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
* installed module declares no artifact for the requested UI role, or the requested version is not
* the installed one.
*
* They are deliberately indistinguishable. The route requires no authentication (see
* `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate
* which modules an installation runs and at which versions. Collapsing them means a caller must
* already know both the exact module id and the exact version to learn anything at all.
*/
function notPublished(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "MODULE_UI_NOT_PUBLISHED" }, 404);
}
/**
* The artifact was validated at discovery and is not retrievable now — deleted, replaced by a
* directory, or unreadable. That is a server-side condition rather than a bad request, and often a
* transient one (an operator mid-upgrade), so it reports like every other "could not be determined"
* result in this application: a sanitized 503 with a fixed code. Never carries the artifact path,
* the module directory, the `errno`, or the caught error.
*/
function artifactUnavailable(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" }, 503);
}
/**
* RFC 9110 §13.1.2 evaluation of `If-None-Match`: `*` matches any existing representation, and
* every listed entity-tag is compared with the **weak** comparison function, so a client echoing
* `W/"…"` still matches the strong tag this route issued. Anything unparsable simply fails to
* match, which costs a full 200 and can never produce a wrongly-fresh 304.
*/
function isNotModified(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;
});
}
interface PublishedArtifact {
readonly artifactPath: string;
}
/** Serve one exact path Core already validated, with one response contract for both UI roles. */
async function serveArtifact(c: Context, published: PublishedArtifact): Promise<Response> {
// Discovery validated this exact path earlier; the file may still have changed or vanished
// since. The response is produced from whatever a single read of that one path returns right
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
// something else.
let artifact: Buffer;
try {
artifact = await readFile(published.artifactPath);
} catch {
return artifactUnavailable(c);
}
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
// validator can never describe a different revision than the body does.
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
if (isNotModified(c.req.header("if-none-match"), etag)) {
return new Response(null, {
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
status: 304,
});
}
return new Response(artifact, {
headers: {
"Cache-Control": MODULE_UI_CACHE_CONTROL,
"Content-Type": MODULE_UI_CONTENT_TYPE,
ETag: etag,
// The declared type is the only type: a browser must not be allowed to sniff a module
// artifact into some other interpretation, and `import()` requires the JavaScript type
// anyway.
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
}
export interface CreateModuleUiRoutesOptions {
/** The platform's publication policy, built by Core from the discovered, started module
* inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives
* every byte it serves from this registry and nothing else. */
readonly registry: ModuleUiPublicationRegistry;
}
/**
* Publishes installed modules' validated `entrypoints.ui` artifacts at a platform-owned browser
* URL, closing the one gap between Core's server-side artifact path and the generic loader's
* browser-side URL (ADR 0024, ADR 0025).
*
* ## The request never touches the filesystem
*
* `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
* components**. They are handed to the registry and, on an exact match, the already-canonical
* `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
* 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
* becomes a static file root, and only the single artifact its manifest declares is reachable.
*
* ## No authentication, deliberately
*
* Retrieving the artifact confers no authority. The bundle is compiled first-party UI code an
* operator reviewed and installed; it carries no credential, and the host — not the artifact —
* supplies the principal and permissions at mount time. Every module API stays behind
* `requirePermission` against the composed platform registry, and nothing about fetching this file
* changes a single authorization decision.
*
* Requiring a session would also break the thing it is meant to protect: a browser's dynamic
* `import()` uses `credentials: "same-origin"`, so a cross-origin import (the split-origin
* deployments `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` exists to support) sends no cookie and would fail
* — a failure that tests green on a server and only shows up in a real browser, exactly the class of
* error ADR 0024 exists to prevent. Working around it would mean fetching the bytes and importing a
* `blob:` URL, which costs a host its `script-src` CSP.
*
* The honest cost is stated in ADR 0025: an anonymous caller who already knows a module id **and**
* its exact installed version can download that module's UI bundle. That is information disclosure,
* not authority, and it is the reason the failure responses above are indistinguishable.
*
* ## The artifact is never executed
*
* This route reads bytes and writes bytes. It does not `import()`, evaluate, transform, minify,
* re-bundle, or rewrite the artifact, and a module UI whose top level throws is served exactly like
* any other. Core's rule that discovery never executes `entrypoints.ui` (ADR 0021) is unchanged, and
* publication does not become a second, quieter way to run browser code on the server.
*/
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): 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) => {
const published = options.registry.resolveDisplay(
c.req.param("moduleId"),
c.req.param("version"),
);
if (!published) {
return notPublished(c);
}
return serveArtifact(c, published);
});
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => {
const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version"));
if (!published) {
return notPublished(c);
}
return serveArtifact(c, published);
});
return routes;
}
+143
View File
@@ -0,0 +1,143 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { PermissionRegistry } from "@ekklesia/core/authorization";
import {
composePlatformPermissionRegistry,
discoverModules,
type DiscoveredModule,
} from "@ekklesia/core/modules";
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
/** Never carries the underlying package.json path, filesystem error, module directory, manifest
* content, or any other machine detail — a broken repository layout is a startup-time
* configuration problem, not something to describe in detail to whatever eventually logs it. */
export class PlatformPermissionConfigurationError extends Error {
readonly code: PlatformPermissionConfigurationErrorCode;
constructor() {
super("Module discovery configuration could not be resolved.");
this.name = "PlatformPermissionConfigurationError";
this.code = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* Resolves the repository root from this compiled/source file's own location, never
* `process.cwd()` — package-scoped test runs execute with `apps/api` as the working directory,
* which would silently point discovery at the wrong `modules/` directory. `apps/api/dist/*.js`
* (compiled) and `apps/api/src/*.ts` (source, under a test runner transforming TypeScript
* on the fly) sit at the identical depth from the repository root, so the same fixed number of
* `..` segments resolves correctly in both.
*/
function resolveRepositoryRoot(): string {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
}
interface PackageVersionFile {
readonly version?: unknown;
}
async function readPackageVersion(packagePath: string): Promise<string> {
let packageData: PackageVersionFile;
try {
packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersionFile;
} catch {
throw new PlatformPermissionConfigurationError();
}
if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) {
throw new PlatformPermissionConfigurationError();
}
return packageData.version;
}
export interface PrepareStartupModuleInventoryOptions {
/** Test-only escape hatch: overrides the directory module discovery scans for installed
* modules. Never set by the executable entry point — production always discovers the real
* repository's `modules/` directory. Platform and Module SDK versions are always read from the
* real repository's own `package.json` files regardless of this override. */
readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
* on it — and, if startup progresses that far, driving module runtime startup with this exact
* list too. Exists because a genuine exact permission-id collision between two modules is
* unreachable through real discovery: module ids are unique (discovery itself rejects a
* duplicate) and every permission id is namespaced by its own module's id, so no two
* really-discovered modules can ever contribute the same permission id. Proving
* `PermissionRegistryError`'s own duplicate-rejection behavior in composition, and that startup
* has created no pool before it runs, requires supplying deliberately conflicting declarations
* directly. (A module-vs-Core collision, by contrast, *is* reachable through real discovery — a
* module whose own id is `"core"` — and surfaces as `ModulePermissionCompositionError`, not this
* case; `modulesDirectoryOverride` with a real fixture is what proves that one.) Never set by the
* executable entry point.
*/
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
}
export type ComposeStartupPermissionRegistryOptions = PrepareStartupModuleInventoryOptions;
export interface StartupModuleInventory {
/** The exact discovered-module list — in discovery order — permission composition ran against.
* Production startup (`apps/api/src/server.ts`) passes this same list to
* `startDiscoveredModuleRuntime` rather than letting the module runtime rediscover modules a
* second time. */
readonly modules: readonly DiscoveredModule[];
readonly permissionRegistry: PermissionRegistry;
}
/**
* Discovers every installed module's manifest exactly once and composes the one immutable platform
* permission registry (Core's own declarations plus every module's) from that exact discovered
* list — the production source of truth `apps/api` enforces every request against, and the exact
* module inventory `startDiscoveredModuleRuntime` starts from. Only manifests are read here; no
* module server entrypoint is imported or executed, and no module lifecycle hook runs. Read the
* platform and Module SDK versions only when this function actually executes, never at import
* time.
*/
export async function prepareStartupModuleInventory(
options: PrepareStartupModuleInventoryOptions = {},
): Promise<StartupModuleInventory> {
if (options.discoveredModulesOverride) {
const modules = options.discoveredModulesOverride;
return Object.freeze({
modules,
permissionRegistry: composePlatformPermissionRegistry(modules),
});
}
const repositoryRoot = resolveRepositoryRoot();
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
]);
const modules = await discoverModules({
moduleSdkVersion,
modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"),
platformVersion,
});
return Object.freeze({
modules,
permissionRegistry: composePlatformPermissionRegistry(modules),
});
}
/**
* Compatible wrapper for callers — currently only existing tests — that need this package's
* composed registry without the discovered module list itself. (Doctor and `modules:validate` are
* Core-owned and never call into `apps/api` at all; they call Core's own
* `composePlatformPermissionRegistry` directly over their own already-discovered module list.)
* Production `startApi` does not call this wrapper: it calls `prepareStartupModuleInventory`
* directly so it can hand the exact same discovered list to `startDiscoveredModuleRuntime`, rather
* than discarding it here and rediscovering modules a second time for runtime startup.
*/
export async function composeStartupPermissionRegistry(
options: ComposeStartupPermissionRegistryOptions = {},
): Promise<PermissionRegistry> {
const inventory = await prepareStartupModuleInventory(options);
return inventory.permissionRegistry;
}
+814
View File
@@ -0,0 +1,814 @@
import type { Socket } from "node:net";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
EMPTY_MODULE_SECRET_SOURCE,
type ModuleRuntimeServices,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import {
createAuthorizationEvaluator,
createAuthorizationStore,
} from "@ekklesia/core/authorization";
import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices";
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
import {
createIdentityNotifications,
createIdentityRuntime,
type IdentityNotificationReporter,
type IdentityRateLimitOverride,
} from "@ekklesia/core/identity";
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
import {
createModuleConfigurationStore,
createModuleLifecycleStore,
createModuleUiPublicationRegistry,
type DiscoveredModule,
type ModuleConfigurationSettings,
type ModuleEligibilityResolution,
resolveModuleEligibility,
type StartedModuleRuntime,
startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules";
import {
createModuleStorageLookup,
createS3StorageProvider,
type StorageProvider,
} from "@ekklesia/core/storage";
import { type ServerType, serve } from "@hono/node-server";
import type { Hono } from "hono";
import { createApiApp } from "./app.js";
import {
type ClientAddressResolver,
createClientAddressResolver,
wrapFetchWithClientAddress,
} from "./client-address.js";
import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js";
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { withDevicePrincipals } from "./device-principal.js";
import { loadDisplayHostAssets } from "./display-host-assets.js";
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
import { prepareStartupModuleInventory } from "./platform-permissions.js";
const connectionTimeoutMs = 10_000;
const operationTimeoutMs = 10_000;
export interface NodeServerTimeouts {
readonly headersTimeoutMs: number;
readonly requestTimeoutMs: number;
readonly keepAliveTimeoutMs: number;
/**
* How often Node re-scans open connections for a header/request timeout violation
* (`http.Server`'s `connectionsCheckingInterval`). This is not merely a performance knob: Node
* enforces `headersTimeout`/`requestTimeout` only when this periodic check runs, and its own
* default (30s) is verified (`node --version` 24.15.0, direct `http.createServer` reproduction)
* to let a violating connection sit for up to a full additional interval past its actual
* timeout before Node ever closes it — so leaving this at Node's default would silently widen
* both timeouts above by up to 30s in the worst case. Kept small relative to `headersTimeoutMs`
* so a violation is detected promptly after it actually elapses.
*/
readonly connectionsCheckingIntervalMs: number;
}
/**
* Finite Node HTTP server timeouts for this JSON-only API. Node's own defaults
* (`headersTimeout`: 60s, `requestTimeout`: 300s) are generous enough to let a slow-header or
* slow-body connection hold a socket for minutes with no reverse proxy in front; these values bound
* that exposure while remaining comfortable for the small JSON payloads this API ever accepts (see
* `./body-limits.js`). `headersTimeoutMs` must not exceed `requestTimeoutMs` — Node validates this
* relationship itself at server construction — so headers alone may take up to 20s, and the
* complete request (headers plus body) up to 30s, before Node itself responds `408` and closes the
* connection without ever invoking a handler (subject to `connectionsCheckingIntervalMs`'s own
* detection latency, above). `keepAliveTimeoutMs` matches Node's own default (5s) made explicit and
* typed rather than left implicit; it bounds only the idle window between requests on a reused
* connection, never an in-flight one.
*/
const PRODUCTION_NODE_SERVER_TIMEOUTS: NodeServerTimeouts = Object.freeze({
connectionsCheckingIntervalMs: 5_000,
headersTimeoutMs: 20_000,
keepAliveTimeoutMs: 5_000,
requestTimeoutMs: 30_000,
});
/**
* The two fixed operational lines a transport-level client failure may produce. Neither ever
* carries the remote address, request path, query, headers, cookies, body, forwarding chain, the
* caught error's own message, or the malformed bytes that caused it — an attacker controls all of
* those, and a parse failure's message in particular is derived directly from the rejected input.
*/
const NODE_SERVER_TIMEOUT_LOG_MESSAGE =
"[server] A connection was closed for exceeding a configured header/request timeout.";
const NODE_SERVER_CLIENT_ERROR_LOG_MESSAGE =
"[server] A connection was closed after a malformed or oversized client request.";
/**
* Node's own documented default `clientError` responses, reproduced byte-for-byte (verified
* directly against the pinned Node 24 runtime by driving each case through a real socket with no
* listener attached). Registering *any* `clientError` listener suppresses that default entirely —
* `_http_server.js` only falls back when `server.emit("clientError", ...)` returns `false` — so a
* listener that merely observes would silently turn every 408/400/431 into a bare connection reset.
* Reproducing the exact bytes is what lets the platform log the event without changing what a
* caller actually receives.
*/
const NODE_CLIENT_ERROR_RESPONSES: Readonly<Record<string, string>> = Object.freeze({
ERR_HTTP_REQUEST_TIMEOUT: "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n",
HPE_HEADER_OVERFLOW: "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
});
const NODE_CLIENT_ERROR_DEFAULT_RESPONSE = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n";
/**
* Mirrors Node's own `socketOnError` fallback exactly: write the code-appropriate response only
* when the socket is still writable *and* no response has already begun for the in-flight request,
* then destroy the socket. The `_httpMessage` guard is what prevents a second, corrupting status
* line when a handler already responded and only the (unread) request body subsequently timed out.
*/
function writeNodeClientErrorResponse(error: NodeJS.ErrnoException, socket: Socket): void {
const inFlightResponse = (socket as Socket & { _httpMessage?: { headersSent?: boolean } })
._httpMessage;
const alreadyResponding = inFlightResponse?.headersSent === true;
if (socket.writable && !alreadyResponding) {
const code = typeof error.code === "string" ? error.code : "";
socket.write(NODE_CLIENT_ERROR_RESPONSES[code] ?? NODE_CLIENT_ERROR_DEFAULT_RESPONSE);
}
socket.destroy();
}
export interface RunningApi {
readonly app: Hono;
readonly server: ServerType;
shutdown(): Promise<void>;
}
export interface StartApiOptions {
/** Test-only escape hatch: never set by the executable entry point. See
* `IdentityRateLimitOverride` for why production must leave this unset. */
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
* executable entry point. See `prepareStartupModuleInventory`. */
readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping
* filesystem discovery entirely — this exact list drives both permission composition and module
* runtime startup. Never set by the executable entry point. See
* `prepareStartupModuleInventory`. */
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
/** Test-only escape hatch: narrows the Node header/request/keep-alive timeouts so a transport
* test can prove real timeout behavior deterministically without waiting out the production
* values. Never set by the executable entry point. */
readonly nodeServerTimeoutOverride?: Partial<NodeServerTimeouts>;
/** Test-only escape hatch: narrows the graceful HTTP drain deadline
* (`HTTP_DRAIN_TIMEOUT_MS`) so a forced close can be proven in milliseconds. Never set by the
* executable entry point. */
readonly drainTimeoutMsOverride?: number;
/** Test-only escape hatch: narrows the per-module stop/rollback hook deadline
* (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */
readonly moduleHookTimeoutMsOverride?: number;
/**
* Where deployment-supplied module secrets come from (ADR 0029) — the one *production* dependency
* in this options object rather than a test-only escape hatch.
*
* It is a dependency rather than a field on `ApiRuntimeConfig` because it is a lookup, not a
* validated value, and because the set of secrets depends on which modules are discovered — which
* is not known when configuration is validated. The executable entry point supplies an
* environment-backed source; omitting it supplies nothing, so a module declaring a required secret
* is reported `secret_missing` rather than silently starting without it.
*/
readonly moduleSecrets?: ModuleSecretSource;
}
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
try {
await handle.close();
} catch {
// Cleanup failures are swallowed here deliberately: they must never mask a startup failure
// that is already propagating, and there is no raw driver detail safe to surface.
}
}
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the mail provider's transport. */
async function safeCloseMail(provider: MailProvider | undefined): Promise<void> {
if (!provider) {
return;
}
try {
await provider.close();
} catch {
// See safeCloseDatabase: never mask a failure that is already propagating.
}
}
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the storage provider's own
* connection pool. */
async function safeCloseStorage(provider: StorageProvider | undefined): Promise<void> {
if (!provider) {
return;
}
try {
await provider.close();
} catch {
// See safeCloseDatabase: never mask a failure that is already propagating.
}
}
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, used only while unwinding a startup
* failure that already occurred after the module runtime started — never during normal shutdown,
* where a module stop failure remains an observable result instead (see `startApi`'s `shutdown`). */
async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promise<void> {
try {
await moduleRuntime.stop();
} catch {
// See safeCloseDatabase: never mask the startup failure already propagating.
}
}
/**
* The platform's fixed graceful-drain deadline. In-flight HTTP work has this long to finish after
* the listener stops accepting new connections; after it, connections are closed so process
* shutdown stays bounded. Not configurable in this slice.
*/
export const HTTP_DRAIN_TIMEOUT_MS = 30_000;
/**
* A shutdown that did not complete cleanly. Carries only fixed booleans and the already-sanitized
* module-runtime error — never a request, socket, URL, credential, SQL, or stack detail — so an
* operator can see *which* stages failed without any of it becoming a disclosure channel.
*/
export class ApiShutdownError extends Error {
readonly drainTimedOut: boolean;
readonly moduleStopFailed: boolean;
readonly moduleStopError: unknown;
constructor(drainTimedOut: boolean, moduleStopError: unknown) {
super("The API did not shut down cleanly.");
this.name = "ApiShutdownError";
this.drainTimedOut = drainTimedOut;
this.moduleStopFailed = moduleStopError !== undefined;
this.moduleStopError = moduleStopError;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* Stops accepting new connections, then waits up to `timeoutMs` for existing HTTP work to finish.
*
* At the deadline it closes idle and then still-active connections using Node's own supported
* public `Server` APIs (`closeIdleConnections`/`closeAllConnections`, both called only after
* `server.close()`), and stops waiting for the close callback. No private socket or server field is
* touched.
*
* Force-closing a connection ends the transport. It does **not** prove that arbitrary in-process
* handler JavaScript was canceled — a handler promise may still be running and may still settle.
* What is guaranteed is that shutdown no longer waits for it, and that the pool closes afterwards,
* so its late database work fails.
*/
/** `ServerType` is a union that also covers HTTP/2 servers, which do not expose these two methods.
* `serve()` here always creates a plain `http.Server`, which does; narrowing with a real capability
* check keeps that honest instead of asserting a cast. */
interface ConnectionClosingServer {
closeAllConnections(): void;
closeIdleConnections(): void;
}
function supportsConnectionClosing(
server: ServerType,
): server is ServerType & ConnectionClosingServer {
const candidate = server as Partial<ConnectionClosingServer>;
return (
typeof candidate.closeIdleConnections === "function" &&
typeof candidate.closeAllConnections === "function"
);
}
async function drainHttpServer(
server: ServerType,
timeoutMs: number,
): Promise<"drained" | "forced"> {
let timer: ReturnType<typeof setTimeout> | undefined;
const closed = new Promise<void>((resolve) => {
server.close(() => resolve());
});
try {
const outcome = await Promise.race([
closed.then(() => "drained" as const),
new Promise<"forced">((resolve) => {
timer = setTimeout(() => resolve("forced"), timeoutMs);
}),
]);
if (outcome === "drained") {
return "drained";
}
if (supportsConnectionClosing(server)) {
server.closeIdleConnections();
server.closeAllConnections();
}
return "forced";
} finally {
clearTimeout(timer);
// The close callback may still fire later; nothing awaits it, and it carries no rejection.
}
}
/**
* Binds the Node HTTP server. Two platform-owned transport concerns are applied at this exact
* boundary, below Hono entirely: `wrapFetchWithClientAddress` resolves and sanitizes the caller's
* address from the real socket peer before any route (including Better Auth's own rate limiter)
* ever sees the request (see `./client-address.js`), and `serverOptions` gives Node finite
* header/request/keep-alive timeouts so a slow-header or slow-body connection cannot hold a socket
* indefinitely. A connection Node itself closes for exceeding one of these timeouts never reaches
* `app.fetch` and never receives a second response — both are Node's own documented behavior for
* `headersTimeout`/`requestTimeout`, not something this function re-implements.
*/
function bindServer(
app: Hono,
host: string,
port: number,
clientAddressResolver: ClientAddressResolver,
timeouts: NodeServerTimeouts,
): Promise<ServerType> {
return new Promise((resolve, reject) => {
let settled = false;
const server = serve(
{
fetch: wrapFetchWithClientAddress(app.fetch, clientAddressResolver),
hostname: host,
port,
serverOptions: {
connectionsCheckingInterval: timeouts.connectionsCheckingIntervalMs,
headersTimeout: timeouts.headersTimeoutMs,
keepAliveTimeout: timeouts.keepAliveTimeoutMs,
requestTimeout: timeouts.requestTimeoutMs,
},
},
() => {
if (!settled) {
settled = true;
resolve(server);
}
},
);
// `headersTimeout`/`requestTimeout` do NOT surface as the server's general `'timeout'` event —
// that event only fires for `server.setTimeout()`'s idle-socket timer, which this server never
// arms. Verified directly against the pinned Node 24 runtime by driving a real slow-header and
// slow-body connection through a real socket: the only server-level event either produces is
// `'clientError'`, carrying `err.code === "ERR_HTTP_REQUEST_TIMEOUT"`. Observing the failure
// therefore has to happen here, and — because registering a listener suppresses Node's own
// default response — this handler must reproduce that default itself.
server.on("clientError", (error: NodeJS.ErrnoException, socket: Socket) => {
console.error(
error.code === "ERR_HTTP_REQUEST_TIMEOUT"
? NODE_SERVER_TIMEOUT_LOG_MESSAGE
: NODE_SERVER_CLIENT_ERROR_LOG_MESSAGE,
);
writeNodeClientErrorResponse(error, socket);
});
server.once("error", () => {
if (!settled) {
settled = true;
reject(new Error("The API server failed to start listening."));
}
});
});
}
/**
* Explicit runtime composition, in a fixed order:
*
* 1. Discover every installed module's manifest exactly once and compose the one immutable
* platform permission registry from Core's own declarations plus every module's
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
* facade each get a typed Drizzle view over it — see `./database.js`).
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
* then the Core identity runtime, authorization store, and evaluator.
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
* rediscovering modules a second time — passing it the same services object every module's
* `start`/`stop` hook receives.
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
* UI for that same eligible set, and passing the exact same services object to every module HTTP
* handler.
* 7. Bind the Node HTTP server.
*
* Discovery and permission composition happen before any pool, Better Auth instance, module
* entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
* directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool,
* module database facade, identity, authorization) exists before any executable module lifecycle
* code runs, and every module route is published only after the entire module runtime has
* registered and started successfully, so no request can ever reach a module before every module
* has started. A module that is disabled, or blocked by a disabled required dependency, is never
* imported at all: no entrypoint evaluation, no lifecycle hook, no route, no capability, and no
* published UI artifact — there is no per-request "is this module enabled?" check anywhere, because
* there is nothing mounted to check.
*
* Step 4 makes startup depend on a reachable, migrated database. If the enablement configuration
* cannot be read, startup fails closed with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED` rather
* than starting every discovered module: the platform cannot know what an administrator decided, and
* starting a module they disabled is worse than not starting at all.
*
* Returns a handle with an idempotent `shutdown()` so both the executable entry point and tests can
* stop the server, the module runtime, and the shared pool deterministically. Never applies
* migrations, generates schema, or issues a module database query merely because modules were
* discovered — the database must already be migrated through `pnpm migrations:apply`.
*/
export async function startApi(
config: ApiRuntimeConfig,
options: StartApiOptions = {},
): Promise<RunningApi> {
const inventory = await prepareStartupModuleInventory({
...(options.modulesDirectoryOverride === undefined
? {}
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
...(options.discoveredModulesOverride === undefined
? {}
: { discoveredModulesOverride: options.discoveredModulesOverride }),
});
const databaseHandle = createApiDatabase(config.databaseUrl, {
connectionTimeoutMs,
operationTimeoutMs,
// The one validated transport policy reaches this runtime's single physical pool here; the
// identity, authorization, and module Drizzle views are all typed views over that same pool,
// so TLS never introduces a second connection.
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 runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
let deviceStore: ReturnType<typeof createDeviceStore>;
try {
services = Object.freeze({ database: databaseHandle.module });
runtime = createIdentityRuntime({
config: config.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 } : {}),
});
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
deviceStore = createDeviceStore(databaseHandle.devices);
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
const evaluator = createAuthorizationEvaluator({
// Device authority reaches the one evaluator here. There is no second decision point: a device
// is allowed or denied by the same function, with the same deny-by-default, as a person.
devices: deviceStore,
grants: authorizationStore,
platformOwners: authorizationStore,
});
// Read the administrator's enablement decisions and resolve which discovered modules may actually
// 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
// module an administrator disabled is worse than not starting at all.
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
const moduleConfigurationStore = createModuleConfigurationStore(
databaseHandle.moduleConfiguration,
);
const moduleSecrets = options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE;
let eligibility: ModuleEligibilityResolution;
let moduleConfigurationSettings: ModuleConfigurationSettings;
try {
// Both axes are read before a single module entrypoint is imported, and resolved together in
// one pass (ADR 0029): a module is eligible only if an administrator enabled it, its own
// required configuration is satisfiable, and every module it requires is eligible too. The pass
// also produces each eligible module's frozen configuration snapshot, so the configuration that
// made a module eligible is the identical object it later receives.
// Read sequentially rather than concurrently, deliberately: these are two tiny queries, and
// racing them would make the reported failure depend on which rejected first. An operator
// debugging a broken deployment must get the same error code every time.
const lifecycleConfiguration = await moduleLifecycleStore.readConfiguration();
const settings = await moduleConfigurationStore.readSettings();
moduleConfigurationSettings = settings;
eligibility = resolveModuleEligibility(
inventory.modules,
lifecycleConfiguration,
{ secrets: moduleSecrets, settings },
{ providerConfigured: storageProvider !== undefined },
);
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
// One scoped facade per eligible module, over the exact buckets that module's own manifest
// declares. Built from the eligible set so a blocked module never receives storage at all.
const moduleStorages = createModuleStorageLookup(
storageProvider,
config.storage?.bucketPrefix ?? "",
eligibility.eligibleModules.map((module) => ({
logicalBucketIds: module.manifest.storage.buckets.map((bucket) => bucket.id),
moduleId: module.manifest.id,
})),
);
// This snapshot is inert metadata only: construction reads no artifact and executes no module.
// It lets the API prove that a display host build is available *before* importing any eligible
// module code. The snapshot is not exposed anywhere unless the complete runtime subsequently
// starts and application composition succeeds.
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
try {
displayHostAssets =
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
// so a missing shell build fails here, before any module code is imported.
// The page learns its API origin from the API that serves it, so one build serves either
// topology (ADR 0045).
dashboardHostAssets = await loadDashboardHostAssets(config.identity.baseUrl);
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
// Module load/register/start failures use Core's own existing rollback (already-started
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime
// handle to stop here, only the pool this failure leaves open.
//
// Only eligible modules are started, and startup remains all-or-nothing across *that* set: an
// ineligible module is never imported, so its `register`/`start` hooks never run, it publishes no
// routes and no capabilities, and it cannot take startup down. Eligibility filtering preserves
// discovery's topological order, so a started module's required dependencies still precede it.
let moduleRuntime: StartedModuleRuntime;
try {
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
configurations: eligibility.configurations,
storages: moduleStorages,
...(options.moduleHookTimeoutMsOverride === undefined
? {}
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
});
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
// Set by the first `shutdown()` call, before anything is drained or stopped, so readiness turns
// `503` while the instance still finishes in-flight requests.
let shuttingDown = false;
let app: Hono;
try {
app = createApiApp({
authorization: {
evaluator,
registry: inventory.permissionRegistry,
store: authorizationStore,
},
moduleRoutes: moduleRuntime.routes,
moduleRealtime: moduleRuntime.realtime,
moduleServices: services,
// Built from the exact **eligible** set the runtime just started, never the raw discovered
// inventory, and exposed only after startup succeeded — so the published set is the running
// set. A disabled or dependency-blocked module therefore publishes no artifact, appears in no
// browser inventory, and appears in no resolved Dashboard navigation, by construction rather
// than by a filter somewhere downstream. Modules declaring no UI role are simply absent.
moduleUi,
...(displayHostAssets === undefined ? {} : { displayHostAssets }),
moduleLifecycle: {
// The boot snapshot: what this process discovered, what it resolved, and what the runtime
// reports it actually started — read from the runtime rather than assumed equal to the
// eligible set.
snapshot: Object.freeze({
discoveredModules: inventory.modules,
eligibility: eligibility.entries,
startedModuleIds: Object.freeze(moduleRuntime.modules.map((module) => module.id)),
}),
store: moduleLifecycleStore,
},
moduleConfiguration: {
// The settings this process actually resolved from, so `restartRequired` is a comparison
// against what is running rather than a guess.
bootSettings: moduleConfigurationSettings,
discoveredModules: inventory.modules,
secrets: moduleSecrets,
snapshots: eligibility.configurations,
store: moduleConfigurationStore,
},
moduleStorage: {
providerConfigured: storageProvider !== undefined,
snapshots: moduleStorages,
},
// A typed view over the same single pool as identity, authorization, and the module facade.
dashboard: createDashboardStore(databaseHandle.dashboard),
dashboardHostAssets,
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
health: {
checkDatabase: () => databaseHandle.ping(READINESS_DATABASE_TIMEOUT_MS),
isModuleStartupComplete: () => !shuttingDown,
},
devices: {
// `Secure` follows the deployment's own scheme, read from the already-validated identity
// base URL rather than from any request. Production is https by configuration policy, so a
// real deployment always gets a Secure device cookie; a plaintext development origin would
// silently drop one.
secureCookies: config.identity.baseUrl.startsWith("https://"),
store: deviceStore,
},
// Principal resolution, extended with managed devices. A human session still wins, and this
// is the only place a device credential ever becomes a principal.
runtime: withDevicePrincipals({ devices: deviceStore, runtime }),
// The configured origins plus the API's own (ADR 0045).
trustedOrigins: apiTrustedOrigins(config.identity),
});
} catch (error) {
await safeStopModuleRuntime(moduleRuntime);
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
const clientAddressResolver = createClientAddressResolver(config.clientAddress);
const timeouts: NodeServerTimeouts = {
...PRODUCTION_NODE_SERVER_TIMEOUTS,
...options.nodeServerTimeoutOverride,
};
let server: ServerType;
try {
server = await bindServer(app, config.host, config.port, clientAddressResolver, timeouts);
} catch (error) {
await safeStopModuleRuntime(moduleRuntime);
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
// Memoizes one shared promise rather than a boolean flag: a boolean only prevents a *second*
// cleanup from starting, but a caller that arrives while the first cleanup is still in flight
// would previously get an already-resolved `undefined` immediately, before the listener had
// actually drained or the pool had actually closed. Storing the promise itself means every
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
// observes it complete (or fail) exactly once. Realtime subscriptions are closed *before* HTTP
// drain begins: they are intentionally long-lived HTTP responses and would otherwise keep every
// clean shutdown open until the forced-drain deadline. Order matters, and is now additionally
// load-bearing for the module database facade: the HTTP listener is drained and closed first so
// no new or in-flight request can reach a module handler while the module runtime is stopping;
// then the module runtime stops — its stop hooks may still use `services.database`, which is
// safe because the pool is not yet closed; only then does the shared pool close, so no module
// code can run against it afterward. Unlike the startup-cleanup paths above, a module-stop
// failure here is not swallowed — it is a genuine shutdown result a caller should observe — but
// database cleanup still always runs first, so a stop failure never leaves the pool open.
const drainTimeoutMs = options.drainTimeoutMsOverride ?? HTTP_DRAIN_TIMEOUT_MS;
let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => {
if (!shutdownPromise) {
shuttingDown = true;
shutdownPromise = (async () => {
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
// completes: an in-flight module command may still commit and publish its resulting state.
// `moduleRuntime.stop()` deactivates publishers immediately before stop hooks run.
moduleRuntime.realtime.closeSubscriptions();
const drainOutcome = await drainHttpServer(server, drainTimeoutMs);
let moduleStopError: unknown;
try {
await moduleRuntime.stop();
} catch (error) {
moduleStopError = error;
}
// After the module runtime has stopped, so a `stop` hook may still use its storage
// facade, and before the pool closes, matching the existing ordering discipline exactly.
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
// Every stage above ran regardless of what the earlier ones did, so a drain timeout or a
// module-stop failure can never leave the pool open. Both failures are reported together,
// as fixed metadata: a forced drain is a genuine shutdown failure, not a success.
if (drainOutcome === "forced" || moduleStopError) {
throw new ApiShutdownError(
drainOutcome === "forced",
moduleStopError === undefined ? undefined : moduleStopError,
);
}
})();
}
return shutdownPromise;
};
return Object.freeze({ app, server, shutdown });
}
/**
* Wires `SIGINT`/`SIGTERM` to `running.shutdown()` and only then exits — extracted from `main()`
* so it can be exercised directly in tests with a fake `running`/`exit`, without sending a real
* process signal or invoking the real `process.exit` (which would terminate the test runner
* itself). Production composition (`main()`, below) calls this with real `process.exit`.
*/
export function installShutdownSignalHandlers(
running: Pick<RunningApi, "shutdown">,
exit: (code: number) => void = (code) => process.exit(code),
): void {
let signalReceived = false;
const handleSignal = (): void => {
if (signalReceived) {
return;
}
signalReceived = true;
running
.shutdown()
.then(() => exit(0))
.catch(() => exit(1));
};
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
}
/**
* Turns one deployment environment into this process's startup inputs.
*
* Every host infrastructure value comes from `loadHostConfiguration` — the same function
* `pnpm doctor` calls with the same environment — so the runtime and the diagnostic that reports on
* it can never disagree about what this deployment is configured to use. Only the two values that
* describe *this listener* rather than the host's infrastructure are read here.
*/
export function readConfigFromEnvironment(processEnvironment: HostEnvironment): {
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
readonly environment: "development" | "production";
readonly moduleSecrets: ModuleSecretSource;
} {
const host = loadHostConfiguration(processEnvironment);
return {
environment: host.environment,
input: {
clientAddress: host.clientAddress,
databaseTransport: host.databaseTransport,
databaseUrl: host.databaseUrl,
host: processEnvironment.EKKLESIA_API_HOST,
identity: host.identity,
mail: host.mail,
port: processEnvironment.EKKLESIA_API_PORT,
storage: host.storage,
},
moduleSecrets: host.moduleSecrets,
};
}
export async function main(): Promise<void> {
const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
const config = createApiRuntimeConfig(input, { environment });
const running = await startApi(config, { moduleSecrets });
const address = running.server.address();
const boundPort = address && typeof address === "object" ? address.port : config.port;
console.log(`Ekklesia API listening on ${config.host}:${boundPort}`);
installShutdownSignalHandlers(running);
}
function isDirectExecution(): boolean {
const entrypoint = process.argv[1];
return (
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
);
}
if (isDirectExecution()) {
main().catch(() => {
console.error("Ekklesia API failed to start.");
process.exitCode = 1;
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,351 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
parsePermissionId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
createPermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "../src/authorization-middleware.js";
const testPermission = parsePermissionId("core.test.restricted-thing");
const unregisteredPermission = parsePermissionId("core.test.unregistered-thing");
const registry = createPermissionRegistry([
{ baseline: "restricted", description: "A restricted test permission.", id: testPermission },
]);
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
...overrides,
};
}
function fakeEvaluator(overrides: Partial<AuthorizationEvaluator> = {}): AuthorizationEvaluator {
return {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
...overrides,
};
}
function buildTestApp(options: Partial<AuthorizationMiddlewareOptions> = {}) {
const runtime = options.runtime ?? fakeRuntime();
const evaluator = options.evaluator ?? fakeEvaluator();
const middlewareOptions: AuthorizationMiddlewareOptions = { evaluator, registry, runtime };
const app = new Hono();
app.get("/protected", requirePermission(middlewareOptions, testPermission), (c) => {
const principal = getAuthorizedPrincipal(c);
return c.json({ principalKind: principal.kind });
});
app.get("/unregistered", requirePermission(middlewareOptions, unregisteredPermission), (c) =>
c.json({ ok: true }),
);
app.get("/unprotected", (c) => {
let threw = false;
try {
getAuthorizedPrincipal(c);
} catch {
threw = true;
}
return c.json({ threw });
});
return { app, evaluator, runtime };
}
describe("requirePermission", () => {
it("allows the request through and stores the resolved principal on context when the decision is allowed", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
});
const { app } = buildTestApp({ runtime });
const response = await app.request("/protected");
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ principalKind: "authenticated" });
});
it("returns a sanitized 403 when the evaluator denies", async () => {
const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) });
const { app } = buildTestApp({ evaluator });
const response = await app.request("/protected");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("returns a sanitized 403 for an unregistered permission without ever resolving the principal", async () => {
const runtime = fakeRuntime();
const { app } = buildTestApp({ runtime });
const response = await app.request("/unregistered");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
});
it("returns a sanitized 503, not a 403, when principal resolution fails", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")),
});
const { app } = buildTestApp({ runtime });
const response = await app.request("/protected");
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
});
it("returns a sanitized 503, not a 403, when evaluation itself fails", async () => {
const evaluator = fakeEvaluator({
authorize: vi.fn().mockRejectedValue(new Error("connection terminated")),
});
const { app } = buildTestApp({ evaluator });
const response = await app.request("/protected");
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
});
it("never leaks a raw infrastructure error message through either sanitized 503", async () => {
const hostileMessage = "password=hunter2 host=db.internal.example port=5432";
const evaluator = fakeEvaluator({
authorize: vi.fn().mockRejectedValue(new Error(hostileMessage)),
});
const { app } = buildTestApp({ evaluator });
const response = await app.request("/protected");
const text = await response.text();
expect(text).not.toContain("hunter2");
expect(text).not.toContain("db.internal.example");
});
it("passes the registry's own baseline and permission id to the evaluator, never a client-supplied value", async () => {
const evaluator = fakeEvaluator();
const { app } = buildTestApp({ evaluator });
// A hostile client cannot even express "use this baseline instead" — there is no request
// field the middleware reads for it — but sending an unrelated header proves it is ignored.
await app.request("/protected", { headers: { "x-baseline": "public" } });
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({ baseline: "restricted", permission: testPermission }),
);
});
});
describe("requirePermission with module-owned declarations", () => {
// requirePermission has no concept of "Core" versus "module" — it only ever consults whatever
// registry it is given. These declarations are shaped exactly like what
// composePlatformPermissionRegistry would contribute from a real installed module (an id
// namespaced under a module, not `core.*`), proving the shared enforcement mechanism works
// identically for module-owned permissions without any module-specific code path.
const modulePublicPermission = parsePermissionId("calendar.events.list");
const moduleAuthenticatedPermission = parsePermissionId("calendar.events.subscribe");
const moduleRestrictedPermission = parsePermissionId("calendar.events.read");
const moduleRegistry = createPermissionRegistry([
{
baseline: "public",
description: "List public calendar events.",
id: modulePublicPermission,
},
{
baseline: "authenticated",
description: "Subscribe to calendar event updates.",
id: moduleAuthenticatedPermission,
},
{
baseline: "restricted",
description: "Read calendar events.",
id: moduleRestrictedPermission,
},
]);
function buildModuleApp(
permissionId: typeof modulePublicPermission,
options: Partial<AuthorizationMiddlewareOptions> = {},
) {
const runtime = options.runtime ?? fakeRuntime();
const evaluator = options.evaluator ?? fakeEvaluator();
const middlewareOptions: AuthorizationMiddlewareOptions = {
evaluator,
registry: moduleRegistry,
runtime,
};
const app = new Hono();
app.get("/module-protected", requirePermission(middlewareOptions, permissionId), (c) =>
c.json({ ok: true }),
);
return { app, evaluator, runtime };
}
it("resolves a module public permission's trusted baseline and passes it to the evaluator unchanged", async () => {
const evaluator = fakeEvaluator();
const { app } = buildModuleApp(modulePublicPermission, { evaluator });
const response = await app.request("/module-protected");
expect(response.status).toBe(200);
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({ baseline: "public", permission: modulePublicPermission }),
);
});
it("resolves a module authenticated permission's trusted baseline and passes it to the evaluator unchanged", async () => {
const evaluator = fakeEvaluator();
const { app } = buildModuleApp(moduleAuthenticatedPermission, { evaluator });
await app.request("/module-protected");
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({
baseline: "authenticated",
permission: moduleAuthenticatedPermission,
}),
);
});
it("resolves a module restricted permission's trusted baseline and passes it to the evaluator unchanged", async () => {
const evaluator = fakeEvaluator();
const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator });
await app.request("/module-protected");
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({
baseline: "restricted",
permission: moduleRestrictedPermission,
}),
);
});
it("allows an anonymous principal for a module permission the evaluator authorizes", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
});
const { app } = buildModuleApp(modulePublicPermission, { runtime });
const response = await app.request("/module-protected");
expect(response.status).toBe(200);
});
it("allows an authenticated principal for a module permission the evaluator authorizes", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
});
const { app } = buildModuleApp(moduleRestrictedPermission, { runtime });
const response = await app.request("/module-protected");
expect(response.status).toBe(200);
});
it("denies a module permission the evaluator does not authorize", async () => {
const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) });
const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator });
const response = await app.request("/module-protected");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("denies a module permission absent from the registry before ever resolving the principal or consulting the evaluator", async () => {
const runtime = fakeRuntime();
const evaluator = fakeEvaluator();
const absentModulePermission = parsePermissionId("scheduler.jobs.read");
const { app } = buildModuleApp(absentModulePermission, { evaluator, runtime });
const response = await app.request("/module-protected");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
});
it("never lets a client-supplied baseline influence the decision for a module permission", async () => {
const evaluator = fakeEvaluator();
const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator });
// A hostile client cannot make a restricted module permission behave as public — there is no
// request field the middleware reads for a baseline override.
await app.request("/module-protected", {
headers: { "x-baseline": "public", "x-permission-baseline": "public" },
});
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({ baseline: "restricted" }),
);
});
});
describe("getAuthorizedPrincipal", () => {
it("throws when called on a route requirePermission never protected", async () => {
const { app } = buildTestApp();
const response = await app.request("/unprotected");
expect(await response.json()).toEqual({ threw: true });
});
});
describe("requireTrustedOrigin", () => {
const trustedOrigins = new Set(["https://dashboard.example.test"]);
function buildOriginApp() {
const app = new Hono();
app.post("/mutate", requireTrustedOrigin(trustedOrigins), (c) => c.json({ ok: true }));
return app;
}
it("allows a request from a trusted origin", async () => {
const app = buildOriginApp();
const response = await app.request("/mutate", {
headers: { origin: "https://dashboard.example.test" },
method: "POST",
});
expect(response.status).toBe(200);
});
it("rejects a request with no Origin header at all", async () => {
const app = buildOriginApp();
const response = await app.request("/mutate", { method: "POST" });
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("rejects a request from an untrusted origin", async () => {
const app = buildOriginApp();
const response = await app.request("/mutate", {
headers: { origin: "https://evil.example.test" },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
});
+113
View File
@@ -0,0 +1,113 @@
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
import { describe, expect, it, vi } from "vitest";
import {
CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE,
createClientAddressResolver,
wrapFetchWithClientAddress,
} from "../src/client-address.js";
function fakeEnv(remoteAddress: string | undefined) {
return {
incoming: { socket: { remoteAddress } },
outgoing: {},
} as unknown as Parameters<typeof wrapFetchWithClientAddress>[0] extends (
request: Request,
env: infer Env,
) => unknown
? Env
: never;
}
describe("wrapFetchWithClientAddress", () => {
it("resolves the trusted address from the real socket peer and sets the internal header before calling the wrapped callback", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
const request = new Request("https://example.test/api/auth/sign-in/email");
await wrapped(request, fakeEnv("203.0.113.9"));
expect(wrappedFetch).toHaveBeenCalledTimes(1);
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("203.0.113.9");
});
it("overwrites — never appends to — a caller-supplied copy of the internal header", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
const request = new Request("https://example.test/api/auth/sign-in/email", {
headers: { [TRUSTED_CLIENT_ADDRESS_HEADER]: "203.0.113.250" },
});
await wrapped(request, fakeEnv("192.0.2.1"));
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("192.0.2.1");
// A single value, never a comma-joined "caller-value, resolved-value" — set(), not append().
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).not.toContain(",");
});
it("returns a sanitized 503 and never invokes the wrapped callback when resolution fails", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrappedFetch = vi.fn().mockResolvedValue(new Response("should not be reached"));
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const response = await wrapped(
new Request("https://example.test/api/auth/sign-in/email"),
fakeEnv(undefined),
);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "CLIENT_ADDRESS_UNRESOLVED" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(wrappedFetch).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
} finally {
errorSpy.mockRestore();
}
});
it("never logs the peer address, headers, or request path on a resolution failure", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrapped = wrapFetchWithClientAddress(vi.fn(), resolver);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
await wrapped(
new Request("https://example.test/api/auth/sign-in/email", {
headers: { cookie: "super-secret-session", "x-forwarded-for": "203.0.113.1" },
}),
fakeEnv("not-a-valid-ip"),
);
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
expect(emitted).not.toContain("super-secret-session");
expect(emitted).not.toContain("203.0.113.1");
expect(emitted).not.toContain("sign-in");
expect(emitted).not.toContain("not-a-valid-ip");
} finally {
errorSpy.mockRestore();
}
});
it("preserves the request method, body, and other headers unrelated to the internal address header", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
const request = new Request("https://example.test/api/auth/sign-up/email", {
body: JSON.stringify({ email: "[email protected]" }),
headers: { "content-type": "application/json", origin: "https://dashboard.example.test" },
method: "POST",
});
await wrapped(request, fakeEnv("192.0.2.1"));
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
expect(forwardedRequest.method).toBe("POST");
expect(forwardedRequest.headers.get("origin")).toBe("https://dashboard.example.test");
expect(await forwardedRequest.text()).toBe(JSON.stringify({ email: "[email protected]" }));
});
});
+360
View File
@@ -0,0 +1,360 @@
import { describe, expect, it } from "vitest";
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
const validSecret = "correct-horse-battery-staple-0123456789";
function validInput() {
return {
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
host: "127.0.0.1",
identity: {
baseUrl: "https://identity.example.test",
secret: validSecret,
trustedOrigins: ["https://identity.example.test"],
},
port: 3000,
};
}
describe("API runtime configuration", () => {
it("accepts valid API and identity configuration", () => {
const config = createApiRuntimeConfig(validInput(), { environment: "production" });
expect(config).toEqual({
clientAddress: { mode: "direct" },
// Production defaults to certificate-verified TLS with no configuration at all.
databaseTransport: { mode: "verify-full" },
databaseUrl: validInput().databaseUrl,
host: "127.0.0.1",
identity: {
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,
secret: validSecret,
trustedOrigins: ["https://identity.example.test"],
},
port: 3000,
});
expect(Object.isFrozen(config)).toBe(true);
});
it("defaults host to loopback and port to 3000 when omitted", () => {
const { host: _host, port: _port, ...rest } = validInput();
const config = createApiRuntimeConfig(rest, { environment: "production" });
expect(config.host).toBe("127.0.0.1");
expect(config.port).toBe(3000);
});
it("permits an explicit non-loopback bind address", () => {
const config = createApiRuntimeConfig(
{ ...validInput(), host: "0.0.0.0" },
{ environment: "production" },
);
expect(config.host).toBe("0.0.0.0");
});
it("rejects a missing database URL", () => {
let captured: unknown;
try {
createApiRuntimeConfig({ ...validInput(), databaseUrl: undefined });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(ApiConfigError);
expect(captured).toMatchObject({ code: "DATABASE_URL_MISSING" });
});
it("rejects an invalid database URL", () => {
for (const databaseUrl of ["", "not-a-url", "mysql://example.test/db", "http://example.test"]) {
let captured: unknown;
try {
createApiRuntimeConfig({ ...validInput(), databaseUrl });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(ApiConfigError);
}
});
it("rejects an empty listen host", () => {
let captured: unknown;
try {
createApiRuntimeConfig({ ...validInput(), host: "" });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "HOST_INVALID" });
});
it("rejects an invalid listen port", () => {
for (const port of [-1, 1.5, 70_000, Number.NaN, "not-a-port"]) {
let captured: unknown;
try {
createApiRuntimeConfig({ ...validInput(), port });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PORT_INVALID" });
}
});
it("accepts port 0 as an explicit request for an OS-assigned ephemeral port", () => {
const config = createApiRuntimeConfig({ ...validInput(), port: 0 });
expect(config.port).toBe(0);
});
it("delegates identity validation to Core's createIdentityConfig, rejecting a short secret", () => {
let captured: unknown;
try {
createApiRuntimeConfig({
...validInput(),
identity: { ...validInput().identity, secret: "too-short" },
});
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "SECRET_TOO_SHORT" });
});
it("explicitly allows local development origins only when environment is development", () => {
const config = createApiRuntimeConfig(
{
databaseUrl: validInput().databaseUrl,
host: "127.0.0.1",
identity: {
baseUrl: "http://localhost:3000",
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
},
port: 3000,
},
{ environment: "development" },
);
expect(config.identity.baseUrl).toBe("http://localhost:3000");
});
it("defaults public signup to disabled and threads an explicit development enablement through to identity config", () => {
expect(createApiRuntimeConfig(validInput()).identity.publicSignupEnabled).toBe(false);
expect(
createApiRuntimeConfig(
{
...validInput(),
identity: {
baseUrl: "http://localhost:3000",
publicSignupEnabled: true,
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
},
},
{ environment: "development" },
).identity.publicSignupEnabled,
).toBe(true);
});
it("refuses a production runtime configuration that enables public signup, before any resource is constructed", () => {
let captured: unknown;
try {
createApiRuntimeConfig(
{
...validInput(),
identity: { ...validInput().identity, publicSignupEnabled: true },
},
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
});
it("defaults client-address configuration to direct mode and threads explicit trusted-proxy configuration through", () => {
expect(createApiRuntimeConfig(validInput()).clientAddress).toEqual({ mode: "direct" });
const config = createApiRuntimeConfig({
...validInput(),
clientAddress: {
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies: ["10.0.0.1"],
},
});
expect(config.clientAddress).toEqual({
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies: ["10.0.0.1"],
});
});
describe("PostgreSQL transport policy", () => {
it("defaults to certificate-verified TLS in production and plaintext in development", () => {
expect(createApiRuntimeConfig(validInput()).databaseTransport).toEqual({
mode: "verify-full",
});
expect(
createApiRuntimeConfig(
{
...validInput(),
identity: {
baseUrl: "http://localhost:3000",
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
},
},
{ environment: "development" },
).databaseTransport,
).toEqual({ mode: "disable" });
});
it("refuses a production configuration that disables TLS", () => {
let captured: unknown;
try {
createApiRuntimeConfig(
{ ...validInput(), databaseTransport: { mode: "disable" } },
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION" });
});
it("rejects a malformed or loosely spelled TLS mode in every environment, without coercion", () => {
for (const mode of [
"VERIFY-FULL",
" verify-full ",
"require",
"verify-ca",
"prefer",
"no-verify",
"true",
true,
1,
"",
]) {
for (const environment of ["production", "development"] as const) {
let captured: unknown;
try {
createApiRuntimeConfig(
{
...validInput(),
databaseTransport: { mode },
...(environment === "development"
? {
identity: {
baseUrl: "http://localhost:3000",
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
},
}
: {}),
},
{ environment },
);
} catch (error) {
captured = error;
}
expect(captured, `expected mode ${JSON.stringify(mode)} to be rejected`).toMatchObject({
code: "DATABASE_TLS_MODE_INVALID",
});
}
}
});
it("rejects a database URL carrying its own TLS parameters, which the pinned driver would let override the policy", () => {
for (const parameter of [
"sslmode=disable",
"sslmode=require",
"sslmode=verify-full",
"ssl=true",
"sslrootcert=/tmp/ca.crt",
"sslcert=/tmp/c.crt",
"sslkey=/tmp/c.key",
"uselibpqcompat=true",
]) {
let captured: unknown;
try {
createApiRuntimeConfig(
{
...validInput(),
databaseUrl: `postgresql://ekklesia:[email protected]:5432/ekklesia?${parameter}`,
},
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured, `expected ${parameter} to be rejected`).toMatchObject({
code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT",
});
}
});
it("never leaks the database URL, credentials, host, or CA material through a transport error", () => {
let captured: unknown;
try {
createApiRuntimeConfig(
{
...validInput(),
databaseTransport: { certificateAuthority: "-----BEGIN CERTIFICATE-----SECRET" },
databaseUrl:
"postgresql://admin:[email protected]:5432/ekklesia?sslmode=disable",
},
{ environment: "production" },
);
} catch (error) {
captured = error;
}
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}\n${JSON.stringify(captured)}`;
expect(publicText).not.toContain("hunter2");
expect(publicText).not.toContain("db.internal.example");
expect(publicText).not.toContain("SECRET");
expect(publicText).not.toContain("sslmode");
});
});
it("never leaks the database URL, secret, or trusted origins through a thrown error", () => {
const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia";
let captured: unknown;
try {
createApiRuntimeConfig({ ...validInput(), databaseUrl: hostileUrl, port: -1 });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(ApiConfigError);
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}\n${JSON.stringify(captured)}`;
expect(publicText).not.toContain(hostileUrl);
expect(publicText).not.toContain("s3cr3t");
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);
}
});
});
+642
View File
@@ -0,0 +1,642 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { DashboardConfiguration } from "@ekklesia/core/dashboard";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const viewerId = parseEkklesiaUserId("33333333-3333-4333-8333-333333333333");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly id: string;
readonly name: string;
readonly withUi?: boolean;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version: "1.0.0",
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
},
id: fixture.id,
manifestVersion: 7,
name: fixture.name,
version: "1.0.0",
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
);
if (fixture.withUi !== false) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
);
}
}
interface InstallOptions {
readonly fixtures?: readonly ModuleFixture[];
readonly principal?: EkklesiaPrincipal;
readonly platformOwner?: boolean;
readonly configuration?: Partial<DashboardConfiguration>;
}
const defaultFixtures: readonly ModuleFixture[] = [
{ id: "video", name: "Video" },
{ id: "livestream", name: "Livestream" },
{ id: "calendar", name: "Calendar" },
];
async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-dashboard-routes-"));
temporaryDirectories.push(modulesDirectory);
for (const fixture of options.fixtures ?? defaultFixtures) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async () => false },
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const store = {
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(),
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app };
}
function signedIn(): EkklesiaPrincipal {
return createAuthenticatedPrincipal(viewerId);
}
interface NavigationEntry {
readonly name: string;
readonly module: {
readonly id: string;
readonly version: string;
readonly ui: { readonly url: string };
readonly api: { readonly baseUrl: string };
readonly permissions: readonly string[];
};
}
interface NavigationBody {
readonly groups: ReadonlyArray<{
readonly id: string;
readonly label: string;
readonly order: number;
readonly modules: readonly NavigationEntry[];
}>;
readonly ungrouped: readonly NavigationEntry[];
}
async function navigationOf(app: Hono): Promise<NavigationBody> {
const response = await app.request(DASHBOARD_NAVIGATION_PATH);
expect(response.status).toBe(200);
return (await response.json()) as NavigationBody;
}
/** An administrator: a platform owner, who passes the evaluator's owner bypass for the restricted
* manage permission exactly as in production. */
async function administrator(configuration: Partial<DashboardConfiguration> = {}) {
return install({ configuration, platformOwner: true, principal: signedIn() });
}
function mutate(app: Hono, method: "PUT" | "DELETE", url: string, body?: unknown) {
return app.request(url, {
...(body === undefined
? {}
: { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
headers: {
origin: trustedOrigin,
...(body === undefined ? {} : { "content-type": "application/json" }),
},
method,
});
}
describe("Dashboard navigation — reading", () => {
it("shows every mountable module as ungrouped when nothing is configured", async () => {
const { app } = await install({ principal: signedIn() });
const navigation = await navigationOf(app);
expect(navigation.groups).toEqual([]);
// A fresh installation is immediately usable: no initialization write, no manual database edit.
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
"Calendar",
"Livestream",
"Video",
]);
});
it("renders the module's own manifest name, never its id", async () => {
const { app } = await install({ principal: signedIn() });
const navigation = await navigationOf(app);
expect(navigation.ungrouped.map((entry) => entry.name)).not.toContain("livestream");
expect(navigation.ungrouped.find((entry) => entry.module.id === "livestream")?.name).toBe(
"Livestream",
);
});
it("groups and orders exactly as the stored configuration says", async () => {
const { app } = await install({
configuration: {
groups: [
{ id: "planning", label: "Planning", sortOrder: 20 },
{ id: "media", label: "Media", sortOrder: 10 },
],
placements: [
{ groupId: "media", moduleId: "livestream", sortOrder: 20 },
{ groupId: "media", moduleId: "video", sortOrder: 10 },
{ groupId: "planning", moduleId: "calendar", sortOrder: 10 },
],
},
principal: signedIn(),
});
const navigation = await navigationOf(app);
expect(
navigation.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]),
).toEqual([
["Media", ["Video", "Livestream"]],
["Planning", ["Calendar"]],
]);
expect(navigation.ungrouped).toEqual([]);
});
it("embeds the generic mount descriptor unchanged inside each entry", async () => {
const { app } = await install({ principal: signedIn() });
const navigation = await navigationOf(app);
const livestream = navigation.ungrouped.find((entry) => entry.module.id === "livestream");
// Presentation wraps the host-neutral descriptor; it never rewrites it.
expect(livestream?.module).toEqual({
contributions: [],
api: { baseUrl: "/api/v1/modules/livestream" },
display: null,
id: "livestream",
permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
ui: { url: "/api/v1/module-ui/livestream/1.0.0" },
version: "1.0.0",
});
expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name"]);
});
it("agrees exactly with the generic inventory about what is mountable", async () => {
const { app } = await install({
configuration: {
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
},
principal: signedIn(),
});
const navigation = await navigationOf(app);
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
modules: ReadonlyArray<{ id: string }>;
};
const navigationModules = [
...navigation.groups.flatMap((group) => group.modules),
...navigation.ungrouped,
].map((entry) => entry.module);
// Both derive from one publication registry through one shared description, so they cannot
// disagree about what exists or about any URL.
expect(navigationModules.map((module) => module.id).sort()).toEqual(
inventory.modules.map((module) => module.id).sort(),
);
});
it("keeps the generic inventory free of presentation metadata", async () => {
const { app } = await install({ principal: signedIn() });
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
// The host-neutral contract a standalone host consumes must not learn Ekklesia has folders or
// display names. That layering is the whole reason navigation is a separate route.
for (const field of ["name", "label", "group", "order", "ungrouped"]) {
expect(body, field).not.toContain(`"${field}"`);
}
});
it("omits a module that is no longer installed, without breaking navigation", async () => {
const { app } = await install({
configuration: {
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [
{ groupId: "media", moduleId: "video", sortOrder: 10 },
{ groupId: "media", moduleId: "removed-module", sortOrder: 20 },
],
},
fixtures: [{ id: "video", name: "Video" }],
principal: signedIn(),
});
const navigation = await navigationOf(app);
expect(navigation.groups[0]?.modules.map((entry) => entry.module.id)).toEqual(["video"]);
expect(JSON.stringify(navigation)).not.toContain("removed-module");
});
it("keeps a newly installed module visible alongside an existing arrangement", async () => {
const { app } = await install({
configuration: {
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
},
principal: signedIn(),
});
const navigation = await navigationOf(app);
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]);
// Calendar and Livestream were never configured and are still reachable.
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Livestream"]);
});
it("excludes a module that ships no UI, exactly like the inventory does", async () => {
const { app } = await install({
fixtures: [
{ id: "video", name: "Video" },
{ id: "roster", name: "Roster", withUi: false },
],
principal: signedIn(),
});
const navigation = await navigationOf(app);
expect(JSON.stringify(navigation)).not.toContain("Roster");
});
it("leaks no filesystem path, package root, or manifest internal", async () => {
const { app } = await install({ principal: signedIn() });
const body = await (await app.request(DASHBOARD_NAVIGATION_PATH)).text();
expect(body).not.toContain(tmpdir());
expect(body).not.toContain("dist");
expect(body).not.toContain("@test/module-");
expect(body).not.toContain("ekklesia.module.json");
expect(body).not.toContain("entrypoints");
expect(body).not.toContain("manifestVersion");
});
it("is per-principal, uncached, and refused to anonymous callers", async () => {
const { app: anonymous } = await install();
const { app } = await install({ principal: signedIn() });
const denied = await anonymous.request(DASHBOARD_NAVIGATION_PATH);
const allowed = await app.request(DASHBOARD_NAVIGATION_PATH, {
headers: { origin: trustedOrigin },
});
expect(denied.status).toBe(403);
expect(await denied.text()).not.toContain("Livestream");
expect(allowed.headers.get("cache-control")).toBe("no-store");
const vary = allowed.headers.get("vary")?.toLowerCase() ?? "";
expect(vary).toContain("origin");
expect(vary).toContain("cookie");
});
});
describe("Dashboard navigation — organizing", () => {
it("creates a group and places a module, and both persist into the resolved view", async () => {
const { app } = await administrator();
expect(
(
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
label: "Media",
order: 10,
})
).status,
).toBe(200);
expect(
(
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
groupId: "media",
order: 10,
})
).status,
).toBe(200);
const navigation = await navigationOf(app);
expect(navigation.groups.map((group) => group.label)).toEqual(["Media"]);
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]);
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Video"]);
});
it("renames a group without moving anything", async () => {
const { app } = await administrator({
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "livestream", sortOrder: 10 }],
});
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
label: "Sunday Production",
order: 10,
});
const navigation = await navigationOf(app);
expect(navigation.groups[0]).toMatchObject({ id: "media", label: "Sunday Production" });
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]);
});
it("moves a module between groups and changes only its placement", async () => {
const { app } = await administrator({
groups: [
{ id: "media", label: "Media", sortOrder: 10 },
{ id: "sunday-production", label: "Sunday Production", sortOrder: 5 },
],
placements: [
{ groupId: "media", moduleId: "livestream", sortOrder: 10 },
{ groupId: "media", moduleId: "video", sortOrder: 20 },
],
});
const before = await navigationOf(app);
const livestreamBefore = before.groups
.flatMap((group) => group.modules)
.find((entry) => entry.module.id === "livestream");
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
groupId: "sunday-production",
order: 10,
});
const after = await navigationOf(app);
const livestreamAfter = after.groups
.flatMap((group) => group.modules)
.find((entry) => entry.module.id === "livestream");
// The one property this whole pass exists to guarantee: the module — its artifact URL, its API
// root, its permissions, its version, its name — is byte-identical across the move.
expect(livestreamAfter).toEqual(livestreamBefore);
expect(
after.groups.find((group) => group.id === "sunday-production")?.modules.map((e) => e.name),
).toEqual(["Livestream"]);
expect(after.groups.find((group) => group.id === "media")?.modules.map((e) => e.name)).toEqual([
"Video",
]);
});
it("ungroups a module deliberately", async () => {
const { app } = await administrator({
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
});
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, {
groupId: null,
order: 5,
});
const navigation = await navigationOf(app);
expect(navigation.groups[0]?.modules).toEqual([]);
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
"Video",
"Calendar",
"Livestream",
]);
});
it("ungroups a deleted group's modules rather than losing them", async () => {
const { app } = await administrator({
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
});
const deleted = await mutate(app, "DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`);
const navigation = await navigationOf(app);
expect(deleted.status).toBe(204);
expect(navigation.groups).toEqual([]);
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
"Video",
"Calendar",
"Livestream",
]);
});
it("refuses a placement naming a group that does not exist", async () => {
const { app } = await administrator();
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, {
groupId: "not-a-group",
order: 10,
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "DASHBOARD_GROUP_NOT_FOUND" });
});
it("refuses a body carrying anything beyond the declared fields", async () => {
const { app } = await administrator();
const bodies: readonly unknown[] = [
{ label: "Media", order: 10, parentGroupId: "root" },
{ label: "Media", order: 10, ownerModuleId: "video" },
{ label: "Media" },
{ label: "", order: 10 },
{ label: "Media", order: -1 },
{ label: "Media", order: 1.5 },
{ label: 42, order: 10 },
[],
null,
];
for (const body of bodies) {
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, body);
expect(response.status, JSON.stringify(body)).toBe(400);
expect(await response.json(), JSON.stringify(body)).toEqual({ code: "INVALID_REQUEST" });
}
});
it("refuses a malformed group identifier in the path", async () => {
const { app } = await administrator();
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/Not%20Valid`, {
label: "Media",
order: 10,
});
expect(response.status).toBe(400);
});
});
describe("Dashboard navigation — authority", () => {
it("refuses organizing to a signed-in user without the manage permission", async () => {
// Reading works for any signed-in user; organizing does not.
const { app } = await install({ principal: signedIn() });
expect((await app.request(DASHBOARD_NAVIGATION_PATH)).status).toBe(200);
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
label: "Media",
order: 10,
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("refuses organizing from an untrusted origin, before authorization even runs", async () => {
const { app } = await administrator();
const response = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
body: JSON.stringify({ label: "Media", order: 10 }),
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "PUT",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("refuses organizing to an anonymous caller", async () => {
const { app } = await install();
for (const [method, url, body] of [
["PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { label: "Media", order: 10 }],
["DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, undefined],
["PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, { groupId: null, order: 0 }],
] as const) {
const response = await mutate(app, method, url, body);
expect(response.status, `${method} ${url}`).toBe(403);
}
});
it("grants no module authority to an administrator who reorganizes the Dashboard", async () => {
// The platform owner holds `core.dashboard.navigation.manage` through the owner bypass. That
// says nothing about any module: the hints in the navigation view are still evaluated per
// module permission, and every module route stays enforced on its own.
const { app } = await administrator();
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
label: "Media",
order: 10,
});
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
groupId: "media",
order: 10,
});
const navigation = await navigationOf(app);
const livestream = navigation.groups[0]?.modules[0];
// The fixture modules declare no permissions at all, so an owner still holds none for them:
// moving a module into a folder cannot manufacture a permission hint, let alone a grant.
expect(livestream?.module.permissions).toEqual([]);
});
it("routes no method the navigation contract does not define", async () => {
const { app } = await administrator();
for (const method of ["POST", "PATCH"] as const) {
const response = await app.request(DASHBOARD_NAVIGATION_PATH, {
headers: { origin: trustedOrigin },
method,
});
expect(response.status, method).toBe(404);
}
});
});
@@ -0,0 +1,810 @@
import { randomBytes } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
createAuthorizationDrizzleDatabase,
createInitialPlatformOwner,
} from "@ekklesia/core/authorization";
import {
createBootstrapIdentityCreator,
createIdentityDrizzleDatabase,
} from "@ekklesia/core/identity";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { type RunningApi, startApi } from "../src/server.js";
/**
* Device pairing end to end, against real PostgreSQL and the real HTTP transport (ADR 0036).
*
* The whole point is that none of this is mocked. A screen with no identity asks to be paired, a
* real administrator signs in through the real login transport and approves it, the host issues a
* credential in a real `Set-Cookie`, and the resulting device opens a real display descriptor and a
* real realtime stream — and is then revoked and loses all of it. Anything short of that would
* prove that rows were written, not that a television in a foyer works.
*/
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>();
const trackedDirectories: string[] = [];
let databaseCounter = 0;
const identityBaseUrl = "https://identity.example.test";
const dashboardOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const ownerPassword = "correct horse battery staple";
const displayPermission = "announcements.slideshow.view";
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 {
return (await client.query<T>(text, [...values])).rows;
} catch {
throw new IntegrationHarnessError("An integration database statement failed.");
}
}
async function query<T extends QueryResultRow>(
url: string,
text: string,
values: readonly unknown[] = [],
): Promise<T[]> {
const client = new Client({ connectionString: url });
await client.connect();
try {
return await safeQuery<T>(client, text, values);
} finally {
await client.end().catch(() => undefined);
}
}
/** A display-capable module: one display entrypoint, one restricted permission protecting it, and
* one realtime channel gated by that same permission. */
async function createDisplayModuleDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-"));
trackedDirectories.push(directory);
const moduleDirectory = path.join(directory, "announcements");
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: "@test/module-announcements",
type: "module",
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
server: "./dist/server/index.js",
},
id: "announcements",
manifestVersion: 7,
name: "Announcements fixture",
permissions: [
{
baseline: "restricted",
description: "View the announcement slideshow.",
id: displayPermission,
},
],
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default {
id: "announcements",
version: "1.0.0",
register({ realtime }) {
realtime.channel({ channel: "state", permission: ${JSON.stringify(displayPermission)} });
},
start({ realtime }) {
realtime.publish("state", { slides: [] });
},
stop() {},
};\n`,
),
writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"),
]);
return directory;
}
async function createMigratedDatabase(label: string): Promise<string> {
databaseCounter += 1;
const name =
`ekk_device_${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);
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-device-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 });
}
return url;
}
async function startPlatform(url: string, modulesDirectory: string): Promise<RunningApi> {
const config = createApiRuntimeConfig(
{
databaseUrl: url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
// The production posture: no public signup anywhere in this suite.
publicSignupEnabled: false,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin],
},
port: 0,
},
{ environment: "development" },
);
return startApi(config, {
modulesDirectoryOverride: modulesDirectory,
rateLimitOverride: { max: 1000, window: 1 },
});
}
function apiOrigin(running: RunningApi): string {
const address = running.server.address();
if (!address || typeof address !== "object") {
throw new IntegrationHarnessError("The API integration server has no bound address.");
}
return `http://127.0.0.1:${address.port}`;
}
/** Matches a cookie by suffix, because Better Auth prefixes its session cookie with `__Secure-`
* whenever the configured base URL is https — which this harness's is. */
function cookiePair(response: Response, name: string): string | undefined {
for (const header of response.headers.getSetCookie()) {
const pair = header.split(";")[0]?.trim() ?? "";
const cookieName = pair.slice(0, Math.max(0, pair.indexOf("=")));
if (cookieName === name || cookieName.endsWith(name)) {
return pair;
}
}
return undefined;
}
function setCookieFor(response: Response, name: string): string | undefined {
return response.headers.getSetCookie().find((header) => header.startsWith(`${name}=`));
}
async function createOwner(url: string, origin: string): Promise<string> {
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const authorization = createAuthorizationDrizzleDatabase(url);
const identity = createIdentityDrizzleDatabase(url, {});
try {
await createInitialPlatformOwner(
{
authorizationDb: authorization.db,
createIdentity: createBootstrapIdentityCreator(identity.db),
identityDb: identity.db,
},
{ email, name: "Platform Owner", password: ownerPassword },
);
} finally {
await Promise.all([authorization.close(), identity.close()]);
}
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password: ownerPassword }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const cookie = cookiePair(signIn, "better-auth.session_token");
if (signIn.status !== 200 || cookie === undefined) {
throw new IntegrationHarnessError("The platform owner could not sign in.");
}
return cookie;
}
interface Platform {
readonly api: RunningApi;
readonly origin: string;
readonly url: string;
readonly ownerCookie: string;
readonly close: () => Promise<void>;
}
async function platform(label: string): Promise<Platform> {
const url = await createMigratedDatabase(label);
const modulesDirectory = await createDisplayModuleDirectory();
const api = await startPlatform(url, modulesDirectory);
const origin = apiOrigin(api);
const ownerCookie = await createOwner(url, origin);
return {
api,
close: async () => {
await api.shutdown();
},
origin,
ownerCookie,
url,
};
}
/** The device's own browser: no human session, only whatever cookies the host has set on it. */
class DeviceBrowser {
#cookies = new Map<string, string>();
constructor(private readonly origin: string) {}
absorb(response: Response): void {
for (const header of response.headers.getSetCookie()) {
const pair = header.split(";")[0] ?? "";
const separator = pair.indexOf("=");
if (separator === -1) {
continue;
}
const name = pair.slice(0, separator).trim();
const value = pair.slice(separator + 1).trim();
if (value.length === 0 || /max-age=0/iu.test(header)) {
this.#cookies.delete(name);
} else {
this.#cookies.set(name, value);
}
}
}
header(): string {
return [...this.#cookies].map(([name, value]) => `${name}=${value}`).join("; ");
}
has(name: string): boolean {
return this.#cookies.has(name);
}
overwrite(name: string, value: string): void {
this.#cookies.set(name, value);
}
async request(path: string, init: RequestInit = {}): Promise<Response> {
const cookie = this.header();
const response = await fetch(`${this.origin}${path}`, {
...init,
headers: {
origin: dashboardOrigin,
...(cookie.length > 0 ? { cookie } : {}),
...(init.headers as Record<string, string> | undefined),
},
});
this.absorb(response);
return response;
}
post(path: string, body: unknown): Promise<Response> {
return this.request(path, {
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
method: "POST",
});
}
}
function adminRequest(
origin: string,
cookie: string,
path: string,
body?: unknown,
overrides: RequestInit = {},
): Promise<Response> {
return fetch(`${origin}${path}`, {
...(body === undefined ? {} : { body: JSON.stringify(body) }),
headers: {
cookie,
origin: dashboardOrigin,
...(body === undefined ? {} : { "content-type": "application/json" }),
...(overrides.headers as Record<string, string> | undefined),
},
method: body === undefined ? "GET" : "POST",
...overrides,
});
}
/** Drives a screen from "nobody has paired me" to "I hold a device session". */
async function pairDevice(
platform: Platform,
device: DeviceBrowser,
deviceName = "Foyer TV",
): Promise<{ readonly code: string }> {
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
if (requested.status !== 201) {
throw new IntegrationHarnessError("The pairing request was refused.");
}
const { code } = (await requested.json()) as { code: string };
const approved = await adminRequest(
platform.origin,
platform.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName },
);
if (approved.status !== 200) {
throw new IntegrationHarnessError("The pairing approval was refused.");
}
const claimed = await device.post("/api/v1/device-pairing/claims", {});
if (((await claimed.json()) as { status: string }).status !== "paired") {
throw new IntegrationHarnessError("The device was not issued a session.");
}
return { code };
}
afterAll(async () => {
const admin = await openAdministrativeClient().catch(() => undefined);
if (admin) {
try {
for (const name of trackedDatabases) {
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
() => undefined,
);
}
} finally {
await admin.end().catch(() => undefined);
}
}
for (const directory of trackedDirectories.splice(0)) {
await rm(directory, { force: true, recursive: true }).catch(() => undefined);
}
});
describe.sequential("display device pairing, end to end", () => {
it("takes an unpaired screen all the way to a working display, and back off again", async () => {
const running = await platform("e2e");
const device = new DeviceBrowser(running.origin);
try {
// 1. The screen loads. It has no identity, so the descriptor refuses it — but the page it
// needs in order to show a code is served.
expect((await device.request("/display/announcements")).status).toBe(200);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
// 2. It asks to be paired and receives a code plus an HttpOnly claim cookie.
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
expect(requested.status).toBe(201);
const body = (await requested.json()) as Record<string, unknown>;
expect(body.code).toMatch(/^[0-9]{6}$/u);
// The claim secret is never in the body — only in a cookie script cannot read.
expect(JSON.stringify(body)).not.toContain("claim");
const claimCookie = setCookieFor(requested, "ekklesia_device_pairing");
expect(claimCookie).toMatch(/HttpOnly/iu);
// 3. Nothing has happened yet: polling says pending, and the display is still refused.
expect(
(
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
status: string;
}
).status,
).toBe("pending");
// 4. The administrator looks the code up, sees what it wants, and approves it by name.
const lookup = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/lookups",
{ code: body.code },
);
expect(lookup.status).toBe(200);
expect(await lookup.json()).toMatchObject({
moduleId: "announcements",
permissionId: displayPermission,
});
expect(
(
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code: body.code, deviceName: "Foyer TV" },
)
).status,
).toBe(200);
// 5. The screen collects its credential — through Set-Cookie, never through JSON.
const claimed = await device.post("/api/v1/device-pairing/claims", {});
expect(await claimed.json()).toEqual({ status: "paired" });
const sessionCookie = setCookieFor(claimed, "ekklesia_device_session");
expect(sessionCookie).toMatch(/HttpOnly/iu);
expect(sessionCookie).toMatch(/SameSite=Strict/iu);
expect(sessionCookie).toMatch(/Path=\//u);
expect(sessionCookie).not.toMatch(/Domain=/iu);
// The spent pairing cookie is cleared rather than left on the device.
expect(device.has("ekklesia_device_pairing")).toBe(false);
expect(device.has("ekklesia_device_session")).toBe(true);
// 6. The display now works, and reports the device as its viewer.
const descriptor = await device.request("/api/v1/module-displays/announcements");
expect(descriptor.status).toBe(200);
const descriptorBody = (await descriptor.json()) as {
viewer: { kind: string; deviceId?: string };
module: { permissions: string[] };
};
expect(descriptorBody.viewer.kind).toBe("device");
expect(descriptorBody.viewer.deviceId).toMatch(/^[0-9a-f-]{36}$/u);
// Exactly the one permission it was paired for.
expect(descriptorBody.module.permissions).toEqual([displayPermission]);
// 7. Realtime, through the ordinary permission-gated channel.
const stream = await device.request("/api/v1/module-realtime/announcements/state", {
headers: { accept: "text/event-stream" },
});
expect(stream.status).toBe(200);
expect(stream.headers.get("content-type")).toContain("text/event-stream");
await stream.body?.cancel();
// 8. The device is still nobody as far as the Dashboard is concerned.
expect(await (await device.request("/api/v1/principal")).json()).toEqual({
kind: "anonymous",
});
expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403);
expect((await device.request("/api/v1/devices")).status).toBe(403);
// 9. The administrator revokes it.
const devices = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> };
expect(devices.devices).toHaveLength(1);
expect(devices.devices[0]?.name).toBe("Foyer TV");
expect(devices.devices[0]?.permissions).toEqual([displayPermission]);
const revoked = await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${devices.devices[0]?.id}/revoke`,
{},
);
expect(revoked.status).toBe(204);
// 10. Everything it could do, it can no longer do — while still holding its cookie.
expect(device.has("ekklesia_device_session")).toBe(true);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
expect(
(
await device.request("/api/v1/module-realtime/announcements/state", {
headers: { accept: "text/event-stream" },
})
).status,
).toBe(403);
// And the screen can start over: the page it needs for a new code is still served.
expect((await device.request("/display/announcements")).status).toBe(200);
} finally {
await running.close();
}
});
it("stores no usable credential, and puts none in any response body", async () => {
const running = await platform("secrets");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
expect(credential.length).toBeGreaterThan(20);
const stored = await query<{ credential_hash: string; name: string }>(
running.url,
`select credential_hash, name from ekklesia_core_device`,
);
expect(stored).toHaveLength(1);
// The raw credential is nowhere in the database — only an irreversible digest of it.
expect(stored[0]?.credential_hash).not.toContain(credential);
expect(stored[0]?.credential_hash).toMatch(/^[0-9a-f]{64}$/u);
const everything = JSON.stringify(
await query(running.url, `select * from ekklesia_core_device_pairing_request`),
);
expect(everything).not.toContain(credential);
// The descriptor a module's UI can read carries no credential either.
const descriptor = await device.request("/api/v1/module-displays/announcements");
expect(await descriptor.text()).not.toContain(credential);
} finally {
await running.close();
}
});
describe("pairing cannot be talked into anything", () => {
it("derives the permission from the display, never from the request", async () => {
const running = await platform("derive");
const device = new DeviceBrowser(running.origin);
try {
// The browser tries to name its own permission and its own module.
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
permission: "core.devices.manage",
permissionId: "core.devices.manage",
});
expect(requested.status).toBe(201);
const { code } = (await requested.json()) as { code: string };
const lookup = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/lookups",
{ code },
);
// The host recorded the display's own declared permission and ignored the rest.
expect(await lookup.json()).toMatchObject({ permissionId: displayPermission });
const stored = await query<{ permission_id: string }>(
running.url,
`select permission_id from ekklesia_core_device_pairing_request`,
);
expect(stored.map((row) => row.permission_id)).toEqual([displayPermission]);
} finally {
await running.close();
}
});
it("refuses to start pairing for a module that has no display", async () => {
const running = await platform("nodisplay");
const device = new DeviceBrowser(running.origin);
try {
for (const moduleId of ["calendar", "core", "../announcements", ""]) {
const response = await device.post("/api/v1/device-pairing/requests", { moduleId });
expect(response.status, moduleId).toBeGreaterThanOrEqual(400);
}
expect(
await query(running.url, `select id from ekklesia_core_device_pairing_request`),
).toHaveLength(0);
} finally {
await running.close();
}
});
it("issues nothing to someone who knows only the code", async () => {
const running = await platform("codeonly");
const device = new DeviceBrowser(running.origin);
const onlooker = new DeviceBrowser(running.origin);
try {
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
const { code } = (await requested.json()) as { code: string };
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{
code,
deviceName: "Foyer TV",
},
);
// Someone who read the code off the television — and even guessed the request exists —
// holds no claim cookie, so there is nothing for them to collect.
const stolen = await onlooker.post("/api/v1/device-pairing/claims", {});
expect(stolen.status).toBe(400);
expect(onlooker.has("ekklesia_device_session")).toBe(false);
// The real screen still gets its credential.
const claimed = await device.post("/api/v1/device-pairing/claims", {});
expect(await claimed.json()).toEqual({ status: "paired" });
} finally {
await running.close();
}
});
});
describe("approval is an administrator's act", () => {
it("refuses an anonymous or unauthorized caller, and enforces a trusted origin", async () => {
const running = await platform("approve");
const device = new DeviceBrowser(running.origin);
try {
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
const { code } = (await requested.json()) as { code: string };
// Anonymous.
const anonymous = await fetch(`${running.origin}/api/v1/device-pairing/approvals`, {
body: JSON.stringify({ code, deviceName: "Intruder TV" }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
expect(anonymous.status).toBe(403);
// The paired-device cookie is not an administrator either — a device can never approve
// another device, or itself.
const selfApproval = await device.post("/api/v1/device-pairing/approvals", {
code,
deviceName: "Self TV",
});
expect(selfApproval.status).toBe(403);
// A real administrator from an untrusted origin: CSRF policy applies before anything else.
const crossOrigin = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName: "Foyer TV" },
{ headers: { origin: untrustedOrigin } },
);
expect(crossOrigin.status).toBe(403);
// Nothing was approved by any of that.
expect(
(
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
status: string;
}
).status,
).toBe("pending");
} finally {
await running.close();
}
});
it("cannot approve the same code twice, and cannot mint two credentials from one", async () => {
const running = await platform("single");
const device = new DeviceBrowser(running.origin);
try {
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
const { code } = (await requested.json()) as { code: string };
expect(
(
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName: "Foyer TV" },
)
).status,
).toBe(200);
// A second approval of the same code finds nothing pending.
expect(
(
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName: "Second TV" },
)
).status,
).toBe(404);
// Two claims race for one approved request; exactly one credential may exist.
const [first, second] = await Promise.all([
device.post("/api/v1/device-pairing/claims", {}),
device.post("/api/v1/device-pairing/claims", {}),
]);
const statuses = [
((await first.json()) as { status: string }).status,
((await second.json()) as { status: string }).status,
];
expect(statuses.filter((status) => status === "paired")).toHaveLength(1);
expect(await query(running.url, `select id from ekklesia_core_device`)).toHaveLength(1);
} finally {
await running.close();
}
});
});
it("re-pairs a revoked screen into a fresh identity, leaving the old credential dead", async () => {
const running = await platform("repair");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device, "Foyer TV");
const firstCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
const listed = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string }> };
await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
{},
);
await pairDevice(running, device, "Foyer TV (replaced)");
const secondCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
expect(secondCredential).not.toBe(firstCredential);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200);
// Re-pairing creates a new identity rather than reviving the revoked one, so the old
// credential can never come back — presenting it again is simply anonymous.
const revokedBrowser = new DeviceBrowser(running.origin);
revokedBrowser.overwrite("ekklesia_device_session", firstCredential);
expect((await revokedBrowser.request("/api/v1/module-displays/announcements")).status).toBe(
403,
);
const devices = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ revoked: boolean }> };
expect(devices.devices).toHaveLength(2);
expect(devices.devices.filter((entry) => entry.revoked)).toHaveLength(1);
} finally {
await running.close();
}
});
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
const running = await platform("mixed");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const deviceCredential = device.header();
// A browser presenting both: the person still wins, and stays a person.
const both = await fetch(`${running.origin}/api/v1/principal`, {
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
});
expect(((await both.json()) as { kind: string }).kind).toBe("authenticated");
// And that administrator can still open the display for testing, as a human.
const descriptor = await fetch(`${running.origin}/api/v1/module-displays/announcements`, {
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
});
expect(descriptor.status).toBe(200);
expect(((await descriptor.json()) as { viewer: { kind: string } }).viewer.kind).toBe(
"authenticated",
);
} finally {
await running.close();
}
});
});
@@ -0,0 +1,84 @@
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/display-assets", () => ({
displayHostAssetUrls: Object.freeze({
html: "file:///surface/browser/display/index.html",
script: "file:///surface/browser/display/app.js",
serviceWorker: "file:///surface/browser/display/sw.js",
}),
}));
import {
createDisplayHostAssets,
DisplayHostAssetError,
loadDisplayHostAssets,
} from "../src/display-host-assets.js";
describe("display host asset loading", () => {
beforeEach(() => readFileMock.mockReset());
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 script = "export const displayHost = true;\n";
const serviceWorker = "export const displayWorker = true;\n";
readFileMock
.mockResolvedValueOnce(html)
.mockResolvedValueOnce(script)
.mockResolvedValueOnce(serviceWorker);
const assets = await loadDisplayHostAssets();
expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/display/index.html"), "utf8"],
[new URL("file:///surface/browser/display/app.js"), "utf8"],
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
]);
expect(assets).toEqual({
html,
script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
serviceWorker,
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
});
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 loadDisplayHostAssets().catch((error: unknown) => error);
expect(readFileMock).toHaveBeenCalledTimes(1);
expect(failure).toBeInstanceOf(DisplayHostAssetError);
expect(failure).toMatchObject({
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
message: "The display host browser assets are unavailable.",
name: "DisplayHostAssetError",
});
expect((failure as Error).stack).toBe(
"DisplayHostAssetError: The display 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("rejects an empty page, script, or service worker as an unavailable host build", () => {
for (const [html, script, serviceWorker] of [
["", "export {};", "export {};"],
["<html></html>", " \n", "export {};"],
["<html></html>", "export {};", " \n"],
] as const) {
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([]);
});
});
+881
View File
@@ -0,0 +1,881 @@
import {
createAuthenticatedPrincipal,
parseEkklesiaUserId,
parseGrantId,
parsePermissionId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
AuthorizationStoreError,
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_PERMISSION_REGISTRY,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { createGrantsRoutes } from "../src/grants-routes.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const manager = createAuthenticatedPrincipal("manager_1");
const subjectUserId = parseEkklesiaUserId("subject_1");
// Well-formed but never declared in `CORE_PERMISSION_REGISTRY` — used to prove the routes reject
// (or, for listing, still return) an unregistered permission id rather than assuming registration.
const permission = parsePermissionId("calendar.events.read");
// A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach
// `store.createGrant`/`store.revokeGrant` can do so. This suite exercises `createGrantsRoutes` in
// isolation and intentionally injects `CORE_PERMISSION_REGISTRY` directly rather than a composed
// platform registry — the routes have no notion of "Core" versus "module" permissions, they only
// ever consult whichever registry they are given. Coverage proving a module-declared permission
// works through the exact same route code lives in
// `apps/api/tests/authorization-middleware.test.ts` (unit) and the "installed module permission
// end-to-end" scenario in `apps/api/tests/api-http.integration.ts` (live).
const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE;
const grantId = parseGrantId("11111111-1111-1111-1111-111111111111");
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(manager),
...overrides,
};
}
function fakeStore(overrides: Partial<AuthorizationStore> = {}): 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(),
...overrides,
};
}
function buildApp(options: {
readonly allowed?: boolean;
readonly now?: () => Date;
readonly runtime?: EkklesiaIdentityRuntime;
readonly store?: AuthorizationStore;
}) {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: options.allowed ?? true }),
};
const runtime = options.runtime ?? fakeRuntime();
const store = options.store ?? fakeStore();
const app = new Hono();
app.route(
"/",
createGrantsRoutes({
evaluator,
...(options.now === undefined ? {} : { now: options.now }),
registry: CORE_PERMISSION_REGISTRY,
runtime,
store,
trustedOrigins: new Set([trustedOrigin]),
}),
);
return { app, evaluator, runtime, store };
}
describe("GET /api/v1/grants", () => {
it("denies an anonymous or non-manager authenticated caller (evaluator says no)", async () => {
const { app, evaluator } = buildApp({ allowed: false });
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
expect(response.status).toBe(403);
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({ baseline: "restricted" }),
);
});
it("allows a caller the evaluator authorizes — whether by platform ownership or a delegated grants.manage grant, the route cannot tell and does not need to (see core/tests/authorization-evaluator.test.ts for that distinction)", async () => {
const grants = [
{
createdAt: new Date("2026-01-01T00:00:00.000Z"),
id: grantId,
permission,
sourceKind: "explicit" as const,
userId: subjectUserId,
},
];
const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue({ grants }) });
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
const body = (await response.json()) as { grants: unknown[]; nextCursor: string | null };
expect(response.status).toBe(200);
expect(body.grants).toHaveLength(1);
expect(body.nextCursor).toBeNull();
// The default page size is applied server-side; `sourceKind` filtering stays server-side too.
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
limit: 50,
sourceKind: "explicit",
});
});
it("rejects a missing or malformed userId query parameter", async () => {
const { app, store } = buildApp({});
const response = await app.request("/api/v1/grants?userId=");
expect(response.status).toBe(400);
expect(store.listGrantsForUser).not.toHaveBeenCalled();
});
it("maps a store infrastructure failure to a sanitized 503", async () => {
const store = fakeStore({
listGrantsForUser: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED")),
});
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_LIST_FAILED" });
});
});
describe("POST /api/v1/grants", () => {
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
const { app, evaluator } = buildApp({});
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission, subjectUserId }),
headers: { "content-type": "application/json" },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(evaluator.authorize).not.toHaveBeenCalled();
});
it("rejects an untrusted Origin header the same way", async () => {
const { app } = buildApp({});
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission, subjectUserId }),
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("denies a caller the evaluator does not authorize", async () => {
const { app, store } = buildApp({ allowed: false });
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(403);
expect(store.createGrant).not.toHaveBeenCalled();
});
it("creates an explicit grant, deriving sourceKind and actor server-side from the authenticated principal, never from the request body", async () => {
const createdGrant = {
createdAt: new Date("2026-01-01T00:00:00.000Z"),
id: grantId,
permission: registeredPermission,
sourceKind: "explicit" as const,
userId: subjectUserId,
};
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
const { app } = buildApp({ store });
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
const body = (await response.json()) as { grant: { id: string } };
expect(response.status).toBe(201);
expect(body.grant.id).toBe(grantId);
expect(store.createGrant).toHaveBeenCalledWith(
expect.objectContaining({
actor: { type: "user", userId: manager.userId },
permission: registeredPermission,
sourceKind: "explicit",
userId: subjectUserId,
}),
);
// The generic HTTP surface creates unscoped explicit grants only.
expect(store.createGrant).toHaveBeenCalledWith(
expect.not.objectContaining({ scope: expect.anything() }),
);
});
it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => {
const { app, store } = buildApp({});
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it.each([
"actor",
"owner",
"isOwner",
"sourceKind",
"sourceRef",
"id",
"grantId",
"createdAt",
"revokedAt",
"createdByActorId",
"effectivePermissions",
])("rejects a request body carrying a disallowed field (%s), even alongside otherwise-valid fields", async (field) => {
const { app, store } = buildApp({});
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ [field]: "hostile-value", permission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(400);
expect(store.createGrant).not.toHaveBeenCalled();
});
it("rejects a malformed permission, subject id, scope, or expiresAt", async () => {
const { app, store } = buildApp({});
const responses = await Promise.all(
[
{ permission: "not-namespaced", subjectUserId },
{ permission, subjectUserId: "" },
{ permission, scope: "", subjectUserId },
{ expiresAt: "not-a-date", permission, subjectUserId },
].map((body) =>
app.request("/api/v1/grants", {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
}),
),
);
for (const response of responses) {
expect(response.status).toBe(400);
}
expect(store.createGrant).not.toHaveBeenCalled();
});
it("maps a store infrastructure failure to a sanitized 503", async () => {
const store = fakeStore({
createGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED")),
});
const { app } = buildApp({ store });
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" });
});
});
describe("POST /api/v1/grants/:grantId/revoke", () => {
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
const { app, evaluator } = buildApp({});
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { method: "POST" });
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(evaluator.authorize).not.toHaveBeenCalled();
});
it("revokes an explicit grant, deriving the actor server-side from the authenticated principal", async () => {
const store = fakeStore({ revokeGrant: vi.fn().mockResolvedValue(undefined) });
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true });
expect(store.revokeGrant).toHaveBeenCalledWith({
actor: { type: "user", userId: manager.userId },
grantId,
sourceKind: "explicit",
});
});
it("always constrains revocation to sourceKind explicit, so a relationship-derived grant can never be targeted by id", async () => {
// The store itself enforces this by treating a sourceKind mismatch exactly like a missing
// grant; this test proves the route always asks for that constraint, and correctly surfaces
// the resulting "not found" as 404 rather than leaking that the grant exists but is the wrong
// kind.
const store = fakeStore({
revokeGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")),
});
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
expect(store.revokeGrant).toHaveBeenCalledWith(
expect.objectContaining({ sourceKind: "explicit" }),
);
});
it("rejects a malformed grant id", async () => {
const { app, store } = buildApp({});
const response = await app.request("/api/v1/grants/%20/revoke", {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(400);
expect(store.revokeGrant).not.toHaveBeenCalled();
});
it("maps a store infrastructure failure to a sanitized 503", async () => {
const store = fakeStore({
revokeGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED")),
});
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
});
});
describe("Core grant mutation request body limit", () => {
it("accepts a comfortably-under-limit grant-creation body and rejects one byte over the exact limit", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const overSizedBody = "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1);
const overResponse = await app.request("/api/v1/grants", {
body: overSizedBody,
headers: {
"content-length": String(overSizedBody.length),
"content-type": "application/json",
origin: trustedOrigin,
},
method: "POST",
});
expect(overResponse.status).toBe(413);
expect(await overResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(store.createGrant).not.toHaveBeenCalled();
// A well-formed, comfortably-under-limit payload still succeeds through the same route.
const underResponse = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(underResponse.status).not.toBe(413);
});
it("rejects an oversized declared Content-Length on grant creation without invoking permission evaluation", async () => {
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true });
const evaluator: AuthorizationEvaluator = { authorize: evaluatorAuthorize };
const store = fakeStore();
const app = new Hono();
app.route(
"/",
createGrantsRoutes({
evaluator,
registry: CORE_PERMISSION_REGISTRY,
runtime: fakeRuntime(),
store,
trustedOrigins: new Set([trustedOrigin]),
}),
);
const response = await app.request("/api/v1/grants", {
body: "a",
headers: {
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES * 10),
"content-type": "application/json",
origin: trustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(evaluatorAuthorize).not.toHaveBeenCalled();
expect(store.createGrant).not.toHaveBeenCalled();
});
it("rejects an oversized streamed grant-creation body with no declared Content-Length", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const chunk = new Uint8Array(1024).fill(97);
let enqueued = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (enqueued > CORE_MUTATION_MAX_BODY_BYTES) {
controller.close();
return;
}
enqueued += chunk.length;
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/grants", {
body: stream,
duplex: "half",
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it("does not read an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const chunk = new Uint8Array(1024).fill(97);
let pullCount = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pullCount += 1;
if (pullCount > 3) {
controller.close();
return;
}
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/grants", {
body: stream,
duplex: "half",
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it("also bounds the revoke mutation body", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
body: "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1),
headers: {
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES + 1),
origin: trustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(store.revokeGrant).not.toHaveBeenCalled();
});
});
/**
* Pass 3 request-contract regressions. Every case here asserts both the sanitized public response
* *and* that the store was never reached, because the point of each fix is that a malformed or
* unsupported request is decided at the request boundary rather than turning into database work or
* an indistinguishable infrastructure failure.
*/
describe("explicit-grant request invariants", () => {
const validExpiry = "2099-01-01T00:00:00.000Z";
async function post(app: Hono, body: unknown) {
return await app.request("/api/v1/grants", {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
}
it("rejects a scope field in any form, before any store mutation", async () => {
// Core keeps scoped-grant semantics for trusted internal callers, but no HTTP middleware
// supplies a scope to the evaluator, so a scope accepted here could never authorize anything.
for (const scope of ["calendar-event:123", "", " ", null, 42, {}, []]) {
const { app, store } = buildApp({});
const response = await post(app, { permission: registeredPermission, scope, subjectUserId });
expect(response.status, `scope ${JSON.stringify(scope)}`).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(store.createGrant).not.toHaveBeenCalled();
}
});
it("accepts exactly one canonical expiration representation", async () => {
const createdGrant = {
createdAt: new Date("2026-01-01T00:00:00.000Z"),
expiresAt: new Date(validExpiry),
id: grantId,
permission: registeredPermission,
sourceKind: "explicit" as const,
userId: subjectUserId,
};
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
const { app } = buildApp({ store });
const response = await post(app, {
expiresAt: validExpiry,
permission: registeredPermission,
subjectUserId,
});
expect(response.status).toBe(201);
expect(store.createGrant).toHaveBeenCalledWith(
expect.objectContaining({ expiresAt: new Date(validExpiry) }),
);
});
it("rejects normalized-impossible, locale, offset, imprecise, padded, and past expirations", async () => {
const now = new Date("2026-07-26T12:00:00.000Z");
for (const expiresAt of [
// Normalized impossible date: `new Date` silently rewrote this to March 3rd.
"2026-02-31T00:00:00.000Z",
"2026-13-01T00:00:00.000Z",
// Locale-shaped and date-only forms.
"March 5, 2027",
"2027-01-01",
// Missing milliseconds, excess fractional precision, offsets, lowercase separators.
"2027-01-01T00:00:00Z",
"2027-01-01T00:00:00.123456Z",
"2027-01-01T00:00:00.000+02:00",
"2027-01-01t00:00:00.000z",
"2027-01-01T00:00:00.000+00:00",
// Whitespace padding and trailing text.
" 2027-01-01T00:00:00.000Z",
"2027-01-01T00:00:00.000Z ",
"2027-01-01T00:00:00.000Z extra",
// Past and exactly-now are both dead on arrival.
"2020-01-01T00:00:00.000Z",
"2026-07-26T12:00:00.000Z",
// Non-strings.
42,
null,
true,
]) {
const { app, store } = buildApp({ now: () => now });
const response = await post(app, {
expiresAt,
permission: registeredPermission,
subjectUserId,
});
expect(response.status, `expiresAt ${JSON.stringify(expiresAt)}`).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(store.createGrant).not.toHaveBeenCalled();
}
});
it("returns a sanitized 409 for an equivalent grant that is already effective", async () => {
const store = fakeStore({
createGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS")),
});
const { app } = buildApp({ store });
const response = await post(app, { permission: registeredPermission, subjectUserId });
const raw = await response.text();
expect(response.status).toBe(409);
expect(JSON.parse(raw)).toEqual({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
expect(response.headers.get("cache-control")).toBe("no-store");
// The body is the stable code and nothing else — no existing grant id, actor, scope, source
// reference, expiry, SQL, constraint name, or driver text.
expect(Object.keys(JSON.parse(raw))).toEqual(["code"]);
for (const leaked of [
grantId,
manager.userId,
subjectUserId,
"calendar-event",
"constraint",
"select ",
"insert ",
"ekklesia_core_grant",
"Error",
"at ",
]) {
expect(raw, `must not leak ${leaked}`).not.toContain(leaked);
}
});
it("rejects a non-canonical grant id on revoke without ever calling the store", async () => {
for (const raw of [
"not-a-uuid",
"0B36B1B6-9A29-4C4E-9E2D-4C0E4C5E6F70",
"%200b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70",
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f7",
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f700",
"0b36b1b69a294c4e9e2d4c0e4c5e6f70",
]) {
const { app, store } = buildApp({});
const response = await app.request(`/api/v1/grants/${raw}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status, `grantId ${raw}`).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(store.revokeGrant).not.toHaveBeenCalled();
}
});
it("still returns the sanitized 404 for a canonical but unknown grant id", async () => {
const store = fakeStore({
revokeGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")),
});
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
expect(store.revokeGrant).toHaveBeenCalled();
});
});
describe("explicit-grant listing pagination", () => {
function grantAt(index: number, iso: string) {
return {
createdAt: new Date(iso),
id: parseGrantId(`00000000-0000-4000-8000-${String(index).padStart(12, "0")}`),
permission: registeredPermission,
sourceKind: "explicit" as const,
userId: subjectUserId,
};
}
it("applies the default limit of 50 and accepts the 1 and 100 boundaries", async () => {
for (const [query, expected] of [
["", 50],
["&limit=1", 1],
["&limit=50", 50],
["&limit=100", 100],
] as const) {
const { app, store } = buildApp({});
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}${query}`);
expect(response.status, query).toBe(200);
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
limit: expected,
sourceKind: "explicit",
});
}
});
it("rejects every non-canonical or out-of-range limit before any store work", async () => {
for (const limit of [
"0",
"101",
"-1",
"+5",
"1.0",
"1.5",
"01",
"005",
" 5",
"5 ",
"1e2",
"",
"abc",
"0x10",
"Infinity",
"NaN",
]) {
const { app, store } = buildApp({});
const response = await app.request(
`/api/v1/grants?userId=${subjectUserId}&limit=${encodeURIComponent(limit)}`,
);
expect(response.status, `limit ${JSON.stringify(limit)}`).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(store.listGrantsForUser).not.toHaveBeenCalled();
}
});
it("rejects unknown and duplicated query keys", async () => {
for (const query of [
`userId=${subjectUserId}&sort=asc`,
`userId=${subjectUserId}&offset=10`,
`userId=${subjectUserId}&sourceKind=relationship`,
`userId=${subjectUserId}&userId=${subjectUserId}`,
`userId=${subjectUserId}&limit=5&limit=6`,
`userId=${subjectUserId}&cursor=a&cursor=b`,
`userId=${subjectUserId}&cursor=`,
]) {
const { app, store } = buildApp({});
const response = await app.request(`/api/v1/grants?${query}`);
expect(response.status, query).toBe(400);
expect(store.listGrantsForUser).not.toHaveBeenCalled();
}
});
it("emits a cursor only when another page exists, and traverses without duplicates or omissions", async () => {
// Three rows sharing one `created_at`, which is exactly the case a timestamp-only keyset
// boundary would skip or repeat.
const sameInstant = "2026-01-01T00:00:00.000Z";
const all = [grantAt(3, sameInstant), grantAt(2, sameInstant), grantAt(1, sameInstant)];
const seen: string[] = [];
let cursor: string | null = null;
let pages = 0;
do {
const store = fakeStore({
listGrantsForUser: vi.fn().mockImplementation((_userId, listOptions) => {
const startIndex =
listOptions.after === undefined
? 0
: all.findIndex((grant) => grant.id === listOptions.after.id) + 1;
const slice = all.slice(startIndex, startIndex + listOptions.limit);
const more = startIndex + listOptions.limit < all.length;
const last = slice.at(-1);
return Promise.resolve({
grants: slice,
...(more && last ? { nextBoundary: { createdAt: last.createdAt, id: last.id } } : {}),
});
}),
});
const { app } = buildApp({ store });
const url: string = `/api/v1/grants?userId=${subjectUserId}&limit=1${
cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`
}`;
const response = await app.request(url);
const body = (await response.json()) as {
grants: { id: string }[];
nextCursor: string | null;
};
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(body.grants).toHaveLength(1);
seen.push(body.grants[0]?.id as string);
cursor = body.nextCursor;
pages += 1;
} while (cursor !== null && pages < 10);
expect(pages).toBe(3);
expect(seen).toEqual(all.map((grant) => grant.id));
expect(new Set(seen).size).toBe(3);
// The final page ends the traversal explicitly.
expect(cursor).toBeNull();
});
it("refuses a cursor issued for a different subject, and every malformed encoding", async () => {
const boundary = { createdAt: new Date("2026-01-01T00:00:00.000Z"), id: grantId };
const store = fakeStore({
listGrantsForUser: vi.fn().mockResolvedValue({
grants: [grantAt(1, boundary.createdAt.toISOString())],
nextBoundary: boundary,
}),
});
const { app: issuingApp } = buildApp({ store });
const issued = (await (
await issuingApp.request(`/api/v1/grants?userId=${subjectUserId}&limit=1`)
).json()) as { nextCursor: string };
expect(issued.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/u);
const encode = (payload: unknown) =>
Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
const canonical = {
g: grantId,
t: "2026-01-01T00:00:00.000Z",
u: subjectUserId,
v: 1,
};
for (const [label, cursor] of [
// A cursor minted for a different subject must not be replayable against this one.
["other subject", encode({ ...canonical, u: "someone_else_1" })],
["wrong version", encode({ ...canonical, v: 2 })],
["extra field", encode({ ...canonical, extra: "x" })],
["missing field", encode({ g: canonical.g, t: canonical.t, u: canonical.u })],
["invalid timestamp", encode({ ...canonical, t: "2026-01-01" })],
["non-canonical timestamp", encode({ ...canonical, t: "2026-01-01T00:00:00Z" })],
["invalid grant id", encode({ ...canonical, g: "not-a-uuid" })],
["array payload", encode([canonical])],
["not json", Buffer.from("nonsense", "utf8").toString("base64url")],
["padded base64", `${Buffer.from(JSON.stringify(canonical), "utf8").toString("base64")}`],
["malformed base64url", "!!!not-base64!!!"],
["trailing data", `${encode(canonical)}AA`],
] as const) {
const { app, store: pagedStore } = buildApp({});
const response = await app.request(
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(cursor)}`,
);
const raw = await response.text();
expect(response.status, label).toBe(400);
expect(JSON.parse(raw)).toEqual({ code: "INVALID_REQUEST" });
// A rejected cursor reveals nothing it decoded, and does zero store/database work.
expect(raw).not.toContain(grantId);
expect(raw).not.toContain("someone_else_1");
expect(pagedStore.listGrantsForUser).not.toHaveBeenCalled();
}
// The cursor the API itself issued is accepted.
const { app: acceptingApp, store: acceptingStore } = buildApp({});
const accepted = await acceptingApp.request(
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(issued.nextCursor)}`,
);
expect(accepted.status).toBe(200);
expect(acceptingStore.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
after: boundary,
limit: 50,
sourceKind: "explicit",
});
});
});
+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);
});
});
@@ -0,0 +1,177 @@
import { loadHostConfiguration } from "@ekklesia/core/host";
import { createMailProviderConfig } from "@ekklesia/core/mail";
import { createStorageProviderConfig } from "@ekklesia/core/storage";
import { describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { readConfigFromEnvironment } from "../src/server.js";
/**
* `pnpm doctor` and `startApi` must reach the same conclusions from the same environment. They
* previously could not: each executable read its own subset of the variables, and Doctor's subset
* omitted `S3_*` and the module secrets entirely.
*
* These tests assert the property directly — the API's startup inputs and the inputs Doctor
* validates come from one `loadHostConfiguration` call and produce identical validated
* configuration, including every default.
*/
const secretKey = "sk-example-secret-material";
const smtpPassword = "sup3r-secret-relay-password";
function environment(
overrides: Readonly<Record<string, string | undefined>> = {},
): Readonly<Record<string, string | undefined>> {
return {
DATABASE_URL: "postgres://[email protected]:5432/ekklesia",
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
EKKLESIA_IDENTITY_SECRET: "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW",
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
NODE_ENV: "production",
...overrides,
};
}
const configuredInfrastructure = Object.freeze({
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
EKKLESIA_SMTP_HOST: "smtp.example.org",
EKKLESIA_SMTP_PASSWORD: smtpPassword,
EKKLESIA_SMTP_USERNAME: "[email protected]",
S3_ACCESS_KEY: "AKIAEXAMPLEACCESSID",
S3_ENDPOINT: "https://storage.example.org",
S3_SECRET_KEY: secretKey,
});
/** Exactly what Doctor validates, from exactly the same load the API performs. */
function doctorView(env: Readonly<Record<string, string | undefined>>) {
const host = loadHostConfiguration(env);
return {
mail: createMailProviderConfig(host.mail, { environment: host.environment }),
storage: createStorageProviderConfig(host.storage, { environment: host.environment }),
};
}
function apiView(env: Readonly<Record<string, string | undefined>>) {
const { environment: resolved, input } = readConfigFromEnvironment(env);
return createApiRuntimeConfig(input, { environment: resolved });
}
describe("host configuration parity between startApi and Doctor", () => {
it("derives identical object-storage configuration, defaults included", () => {
const env = environment(configuredInfrastructure);
expect(apiView(env).storage).toEqual(doctorView(env).storage);
// Named explicitly rather than only compared, so a change to either default fails here loudly.
expect(apiView(env).storage).toEqual({
accessKey: "AKIAEXAMPLEACCESSID",
bucketPrefix: "ekklesia",
deliveryEndpoint: "https://storage.example.org",
endpoint: "https://storage.example.org",
forcePathStyle: true,
region: "us-east-1",
secretKey,
});
});
it("derives identical outbound-email configuration, defaults included", () => {
const env = environment(configuredInfrastructure);
expect(apiView(env).mail).toEqual(doctorView(env).mail);
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",
port: 587,
security: "starttls",
});
});
it("agrees that an installation with neither service is complete", () => {
const env = environment();
expect(apiView(env).storage).toBeUndefined();
expect(doctorView(env).storage).toBeUndefined();
expect(apiView(env).mail).toBeUndefined();
expect(doctorView(env).mail).toBeUndefined();
});
it("carries every storage field the direct-delivery pass established", () => {
const env = environment({
...configuredInfrastructure,
S3_BUCKET_PREFIX: "rogc",
S3_DELIVERY_ENDPOINT: "https://files.example.org",
S3_FORCE_PATH_STYLE: "false",
S3_REGION: "eu-west-1",
});
expect(apiView(env).storage).toEqual(doctorView(env).storage);
expect(apiView(env).storage).toMatchObject({
bucketPrefix: "rogc",
deliveryEndpoint: "https://files.example.org",
forcePathStyle: false,
region: "eu-west-1",
});
});
it("rejects the same malformed environment with the same sanitized code", () => {
for (const [overrides, code] of [
[{ S3_ENDPOINT: "https://storage.example.org" }, "STORAGE_ACCESS_KEY_MISSING"],
[{ ...configuredInfrastructure, S3_ENDPOINT: "not-a-url" }, "STORAGE_ENDPOINT_INVALID"],
[
{ ...configuredInfrastructure, S3_ENDPOINT: "http://storage.example.org" },
"STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION",
],
[
{ ...configuredInfrastructure, EKKLESIA_SMTP_PASSWORD: undefined },
"MAIL_USERNAME_WITHOUT_PASSWORD",
],
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
[
{ ...configuredInfrastructure, EKKLESIA_SMTP_HELO_NAME: "localhost" },
"MAIL_HELO_NAME_INVALID",
],
] as const) {
const env = environment(overrides);
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
expect(() => doctorView(env)).toThrowError(expect.objectContaining({ code }));
}
});
it("resolves module secrets from the same source the runtime is given", () => {
const env = environment({
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
});
expect(
readConfigFromEnvironment(env).moduleSecrets.get("announcements", "storageCredential"),
).toBe("module-secret-material");
expect(loadHostConfiguration(env).moduleSecrets.get("announcements", "storageCredential")).toBe(
"module-secret-material",
);
});
it("reads only this listener's own address and port outside the shared loader", () => {
const config = apiView(
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
);
expect(config.host).toBe("0.0.0.0");
expect(config.port).toBe(8080);
// Defaults, when unset, stay where they always were.
const defaults = apiView(environment());
expect(defaults.host).toBe("127.0.0.1");
expect(defaults.port).toBe(3000);
});
it("interprets a set-but-empty flag identically instead of one side ignoring it", () => {
// Doctor used to drop any falsy identity value before validating, so `startApi` refused to
// start on `EKKLESIA_PUBLIC_SIGNUP_ENABLED=` while Doctor reported the deployment ready.
const env = environment({ EKKLESIA_PUBLIC_SIGNUP_ENABLED: "" });
expect(loadHostConfiguration(env).identity.publicSignupEnabled).toBe("");
expect(() => apiView(env)).toThrowError(
expect.objectContaining({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" }),
);
});
});
+848
View File
@@ -0,0 +1,848 @@
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import {
createAuthorizationDrizzleDatabase,
createInitialPlatformOwner,
InitialPlatformOwnerError,
} from "@ekklesia/core/authorization";
import {
createBootstrapIdentityCreator,
createIdentityDrizzleDatabase,
} from "@ekklesia/core/identity";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { type RunningApi, startApi } from "../src/server.js";
/**
* The initial platform-owner bootstrap, against a real PostgreSQL and the real HTTP transport
* (ADR 0035).
*
* The point of this suite is that it does **not** stop at asserting rows. A fresh installation is
* bootstrapped and then the resulting account is signed in through the exact browser-facing login
* transport Phase 7B Pass 1 established, its principal is read back, and it is used to reach a
* platform-owner-protected resource. Anything less would prove the command wrote a user, not that
* an operator can actually get into their dashboard — which is the only thing this pass is for.
*
* Every scenario runs with `publicSignupEnabled: false`, the production posture, so nothing here
* can accidentally depend on the signup route the real deployment does not have.
*/
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 bootstrapCliPath = path.join(
repositoryRoot,
"core",
"dist",
"cli",
"create-initial-owner.js",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
const identityBaseUrl = "https://identity.example.test";
const dashboardOrigin = "https://dashboard.example.test";
const ownerPassword = "correct horse battery staple";
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();
}
function makeDatabaseName(label: string): string {
databaseCounter += 1;
return `ekk_owner_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
}
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 createDisposableDatabase(label: string): Promise<string> {
const name = makeDatabaseName(label);
const admin = await openAdministrativeClient();
try {
await safeQuery(admin, `create database "${name}"`);
} finally {
await admin.end().catch(() => undefined);
}
trackedDatabases.add(name);
return databaseUrl(name);
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-owner-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 });
}
}
/** A fresh, migrated installation with no identity and no owner — exactly what an operator has
* after `docker compose up` and `pnpm migrations:apply`. */
async function freshInstallation(label: string): Promise<string> {
const url = await createDisposableDatabase(label);
await applyCoreMigrations(url);
return url;
}
interface BootstrapHandles {
readonly run: (
email: string,
password?: string,
name?: string,
) => ReturnType<typeof createInitialPlatformOwner>;
readonly close: () => Promise<void>;
}
/** Drives the same operation the CLI drives, with the same production composition. */
function bootstrapAgainst(url: string): BootstrapHandles {
const authorization = createAuthorizationDrizzleDatabase(url);
const identity = createIdentityDrizzleDatabase(url, {});
return {
close: async () => {
await Promise.all([
authorization.close().catch(() => undefined),
identity.close().catch(() => undefined),
]);
},
run: (email, password = ownerPassword, name = "Platform Owner") =>
createInitialPlatformOwner(
{
authorizationDb: authorization.db,
createIdentity: createBootstrapIdentityCreator(identity.db),
identityDb: identity.db,
},
{ email, name, password },
),
};
}
/**
* The production posture: public signup disabled, exactly as a real deployment must run — and, just
* 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,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
publicSignupEnabled: false,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin],
},
port: 0,
},
{ environment: "development" },
);
}
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
return startApi(ownerApiConfig(url), { rateLimitOverride: { max: 1000, window: 1 } });
}
function apiOrigin(running: RunningApi): string {
const address = running.server.address();
if (!address || typeof address !== "object") {
throw new IntegrationHarnessError("The API integration server has no bound address.");
}
return `http://127.0.0.1:${address.port}`;
}
function firstCookiePair(setCookieHeader: string): string {
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
}
async function signIn(
origin: string,
email: string,
password: string,
): Promise<{ cookiePair?: string; response: Response }> {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const setCookie = response.headers.get("set-cookie");
return setCookie === null ? { response } : { cookiePair: firstCookiePair(setCookie), response };
}
const execFileAsync = promisify(execFile);
interface CliOutcome {
readonly code: number;
readonly stdout: string;
readonly stderr: string;
}
/** Runs the real compiled CLI as a real child process, with the password on stdin and never in
* `argv`. */
async function runBootstrapCli(
databaseUrl: string | undefined,
args: readonly string[],
password: string | undefined,
): Promise<CliOutcome> {
const environment: Record<string, string> = {
...(process.env as Record<string, string>),
NODE_ENV: "development",
};
if (databaseUrl === undefined) {
delete environment.DATABASE_URL;
} else {
environment.DATABASE_URL = databaseUrl;
}
const child = execFileAsync(process.execPath, [bootstrapCliPath, ...args], {
env: environment,
});
child.child.stdin?.end(password ?? "");
try {
const { stdout, stderr } = await child;
return { code: 0, stderr, stdout };
} catch (error) {
const failure = error as { code?: number; stdout?: string; stderr?: string };
return {
code: typeof failure.code === "number" ? failure.code : 1,
stderr: failure.stderr ?? "",
stdout: failure.stdout ?? "",
};
}
}
async function countRows(url: string, table: string): Promise<number> {
const client = new Client({ connectionString: url });
await client.connect();
try {
const rows = await safeQuery<{ count: string }>(
client,
`select count(*)::text from "${table}"`,
);
return Number(rows[0]?.count ?? "0");
} finally {
await client.end().catch(() => undefined);
}
}
/** 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 () => {
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);
}
});
describe.sequential("initial platform-owner bootstrap", () => {
it("takes a fresh installation all the way to an authenticated owner in the browser", async () => {
const url = await freshInstallation("browserproof");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
let created: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
try {
created = await bootstrap.run(email);
} finally {
await bootstrap.close();
}
expect(created.identity).toBe("created");
expect(created.email).toBe(email.toLowerCase());
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
// 1. The exact browser-facing login transport, on a deployment where signup does not exist.
const session = await signIn(origin, email, ownerPassword);
expect(session.response.status).toBe(200);
expect(session.cookiePair).toBeDefined();
const cookie = session.cookiePair as string;
// 2. The principal the browser bootstrap reads.
const principalResponse = await fetch(`${origin}/api/v1/principal`, { headers: { cookie } });
expect(principalResponse.status).toBe(200);
const principal = (await principalResponse.json()) as {
kind: string;
userId?: string;
};
expect(principal.kind).toBe("authenticated");
expect(principal.userId).toBe(created.userId);
// 3. A platform-owner-protected resource actually opens for them. This is the assertion that
// separates "a row was written" from "the operator can run their platform".
const grants = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`, {
headers: { cookie },
});
expect(grants.status).toBe(200);
// 4. And the same resource is still closed to everyone else.
const anonymous = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`);
expect(anonymous.status).toBe(403);
} finally {
await api.shutdown();
}
});
it("leaves public signup disabled — the owner exists without a signup route existing", async () => {
const url = await freshInstallation("nosignup");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
await bootstrap.run(email);
} finally {
await bootstrap.close();
}
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
const attempted = await fetch(`${origin}/api/auth/sign-up/email`, {
body: JSON.stringify({
email: `intruder-${randomBytes(4).toString("hex")}@example.test`,
name: "Intruder",
password: ownerPassword,
}),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
// The bootstrap command created an account; it did not open a door for anyone else to.
expect(attempted.status).toBe(400);
expect(await attempted.text()).toContain("EMAIL_PASSWORD_SIGN_UP_DISABLED");
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
// And the owner can still sign in, so disabling signup did not disable the account.
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
} finally {
await api.shutdown();
}
});
describe("the guard", () => {
it("refuses a second bootstrap, changes no password, and creates no second owner", async () => {
const url = await freshInstallation("guard");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
await bootstrap.run(email);
// Same address, different password: must not become a password reset.
await expect(bootstrap.run(email, "an entirely different password")).rejects.toMatchObject({
code: "INITIAL_OWNER_ALREADY_COMPLETED",
});
// A different address must not become a second owner.
await expect(
bootstrap.run(`second-${randomBytes(4).toString("hex")}@example.test`),
).rejects.toMatchObject({ code: "INITIAL_OWNER_ALREADY_COMPLETED" });
} finally {
await bootstrap.close();
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
// The refused second attempt with a new address created no stray identity: the sentinel is
// checked before anything is created, not after.
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
expect(
(await signIn(origin, email, "an entirely different password")).response.status,
).not.toBe(200);
} finally {
await api.shutdown();
}
});
});
describe("validation reuses the identity system's own policy", () => {
it("rejects an invalid email, and a password outside the 8–128 bound", async () => {
const url = await freshInstallation("validation");
const bootstrap = bootstrapAgainst(url);
try {
await expect(bootstrap.run("not-an-email-address")).rejects.toMatchObject({
code: "INITIAL_OWNER_EMAIL_INVALID",
});
await expect(
bootstrap.run(`short-${randomBytes(4).toString("hex")}@example.test`, "1234567"),
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_SHORT" });
await expect(
bootstrap.run(`long-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(129)),
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_LONG" });
// Exactly the boundary the browser accepts, so the two can never diverge.
await expect(
bootstrap.run(`edge-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(128)),
).resolves.toMatchObject({ identity: "created" });
} finally {
await bootstrap.close();
}
// Every rejection happened before anything was written, and the one success is the only row.
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
});
it("never puts the password into an error", async () => {
const url = await freshInstallation("nosecrets");
const secret = "sup3rSecretBootstrapPassword!";
const bootstrap = bootstrapAgainst(url);
try {
let caught: unknown;
try {
await bootstrap.run("not-an-email-address", secret);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(InitialPlatformOwnerError);
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
expect(rendered).not.toContain(secret);
expect(rendered).not.toContain("postgres");
expect(rendered).not.toContain("select");
} finally {
await bootstrap.close();
}
});
});
describe("an identity that already exists", () => {
it("adopts a usable account and leaves its password untouched", async () => {
const url = await freshInstallation("adopt");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
// Exactly the partial failure this guards: the identity committed, the grant never ran.
const identity = createIdentityDrizzleDatabase(url, {});
try {
await createBootstrapIdentityCreator(identity.db)({
email,
name: "Half Finished",
password: ownerPassword,
});
} finally {
await identity.close();
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
const bootstrap = bootstrapAgainst(url);
try {
const result = await bootstrap.run(email, "a different password than before");
expect(result.identity).toBe("adopted");
} finally {
await bootstrap.close();
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
// Adoption granted ownership. It did not take the account over: the original password still
// works and the one supplied to the rerun does not.
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
expect(
(await signIn(origin, email, "a different password than before")).response.status,
).not.toBe(200);
} finally {
await api.shutdown();
}
});
it("refuses a credential-less row rather than consuming bootstrap on an unusable owner", async () => {
const url = await freshInstallation("unusable");
const client = new Client({ connectionString: url });
await client.connect();
try {
await safeQuery(
client,
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
values ($1, $2, $3, false, now(), now())`,
["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "Shell Row", "[email protected]"],
);
} finally {
await client.end().catch(() => undefined);
}
const bootstrap = bootstrapAgainst(url);
try {
await expect(bootstrap.run("[email protected]")).rejects.toMatchObject({
code: "INITIAL_OWNER_IDENTITY_UNUSABLE",
});
} finally {
await bootstrap.close();
}
// The decisive part: bootstrap is still available. Granting ownership to a row nobody can
// authenticate as would have consumed the single use and left the installation unusable with
// no second chance.
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
});
});
describe("partial failure", () => {
it("reports a failed grant honestly and stays recoverable by rerunning", async () => {
const url = await freshInstallation("partial");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const authorization = createAuthorizationDrizzleDatabase(url);
const identity = createIdentityDrizzleDatabase(url, {});
try {
await expect(
createInitialPlatformOwner(
{
authorizationDb: authorization.db,
createIdentity: createBootstrapIdentityCreator(identity.db),
grantOwnership: () => {
throw new Error("the grant failed after the identity committed");
},
identityDb: identity.db,
},
{ email, name: "Owner", password: ownerPassword },
),
).rejects.toMatchObject({ code: "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED" });
} finally {
await Promise.all([authorization.close(), identity.close()]);
}
// The identity committed; the grant did not; the single-use sentinel was never consumed.
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
// Rerunning the identical command finishes the job, which is the whole recovery story.
const retry = bootstrapAgainst(url);
try {
await expect(retry.run(email)).resolves.toMatchObject({ identity: "adopted" });
} finally {
await retry.close();
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
});
});
describe("concurrency", () => {
it("gives exactly one owner when several bootstraps race", async () => {
const url = await freshInstallation("race");
const attempts = 5;
const handles = Array.from({ length: attempts }, () => bootstrapAgainst(url));
try {
const outcomes = await Promise.allSettled(
handles.map((handle, index) =>
handle.run(`racer-${index}-${randomBytes(3).toString("hex")}@example.test`),
),
);
const fulfilled = outcomes.filter((outcome) => outcome.status === "fulfilled");
expect(fulfilled).toHaveLength(1);
for (const outcome of outcomes.filter((candidate) => candidate.status === "rejected")) {
expect((outcome as PromiseRejectedResult).reason).toMatchObject({
code: "INITIAL_OWNER_ALREADY_COMPLETED",
});
}
} finally {
await Promise.all(handles.map((handle) => handle.close()));
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(1);
// The losers created nothing at all. Serializing whole invocations — not just the grant — is
// what keeps a race from leaving signable-in accounts that own nothing behind.
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
});
});
describe("the command itself", () => {
it("succeeds, prints something useful, and leaks nothing", async () => {
const url = await freshInstallation("cli");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const result = await runBootstrapCli(url, ["--email", email], ownerPassword);
expect(result.code).toBe(0);
expect(result.stdout).toContain("Initial platform owner created successfully.");
expect(result.stdout).toContain(email.toLowerCase());
const everything = `${result.stdout} ${result.stderr}`;
expect(everything).not.toContain(ownerPassword);
expect(everything).not.toContain("postgresql://");
expect(everything).not.toContain("password");
const api = await startApiWithoutSignup(url);
try {
// The command's real product: an operator who can now sign in.
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
} finally {
await api.shutdown();
}
});
it("refuses a password supplied on the command line", async () => {
const url = await freshInstallation("argv");
const result = await runBootstrapCli(
url,
["--email", "[email protected]", "--password", "hunter2hunter2"],
undefined,
);
expect(result.code).toBe(1);
expect(result.stderr).toContain("--password is not supported");
expect(await countRows(url, "ekklesia_core_user")).toBe(0);
});
it("fails clearly without a database, and refuses to migrate one itself", async () => {
const missing = await runBootstrapCli(
undefined,
["--email", "[email protected]"],
"whatever",
);
expect(missing.code).toBe(1);
expect(missing.stderr).toContain("DATABASE_URL is required.");
// A database that exists but was never migrated is a deployment step that has not happened,
// and this command says so rather than quietly running migrations for a production operator.
const unmigrated = await createDisposableDatabase("unmigrated");
const result = await runBootstrapCli(
unmigrated,
["--email", "[email protected]"],
ownerPassword,
);
expect(result.code).toBe(1);
expect(result.stderr).toContain("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
const client = new Client({ connectionString: unmigrated });
await client.connect();
try {
const tables = await safeQuery<{ count: string }>(
client,
`select count(*)::text from information_schema.tables where table_schema = 'public'`,
);
expect(Number(tables[0]?.count ?? "0")).toBe(0);
} finally {
await client.end().catch(() => undefined);
}
});
it("refuses a second run at the command level too", async () => {
const url = await freshInstallation("clirerun");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
expect((await runBootstrapCli(url, ["--email", email], ownerPassword)).code).toBe(0);
const second = await runBootstrapCli(url, ["--email", email], "a different password");
expect(second.code).toBe(1);
expect(second.stderr).toContain("INITIAL_OWNER_ALREADY_COMPLETED");
expect(second.stderr).not.toContain("a different password");
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
});
});
});
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();
}
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,804 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleConfigurationDeclarationInput,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
type ModuleConfigurationRecord,
type ModuleConfigurationStore,
ModuleConfigurationStoreError,
EMPTY_MODULE_REALTIME_REGISTRY,
resolveModuleEligibility,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import {
createFakeModuleSecretSource,
createInMemoryModuleConfigurationStore,
} from "./support/in-memory-module-configuration.js";
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const viewerId = parseEkklesiaUserId("55555555-5555-4555-8555-555555555555");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
/**
* The configuration shape the real Announcements product would need as an Ekklesia module: a slide
* duration and poll interval with defaults, a bucket name it must be told, and a deployment-supplied
* storage credential.
*
* Modelled from the reference repository, never imported from it. Nothing Announcements-specific
* exists in Core or in `apps/api`; this fixture exists to prove a real product's settings survive
* the whole path — manifest, validation, storage, API, snapshot — without the module reaching any
* platform infrastructure.
*/
const announcementsConfiguration: readonly ModuleConfigurationDeclarationInput[] = [
{
key: "slideSeconds",
type: "integer",
default: 7,
minimum: 1,
maximum: 600,
description: "How long each slide is shown.",
},
{ key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 },
{ key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 },
{ key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" },
{ key: "storageCredential", type: "secret", required: true },
];
interface ModuleFixture {
readonly id: string;
readonly configuration?: readonly ModuleConfigurationDeclarationInput[];
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
configuration: fixture.configuration ?? [],
entrypoints: { server: "./dist/server/index.js" },
id: fixture.id,
manifestVersion: 7,
name: `${fixture.id} module`,
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
),
]);
}
interface InstallOptions {
readonly fixtures?: readonly ModuleFixture[];
readonly principal?: EkklesiaPrincipal;
readonly platformOwner?: boolean;
readonly grants?: readonly string[];
readonly records?: readonly ModuleConfigurationRecord[];
readonly secrets?: Readonly<Record<string, Readonly<Record<string, string>>>>;
readonly store?: ModuleConfigurationStore;
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
readonly store: ModuleConfigurationStore;
}
/**
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
* composition: read the stored settings, resolve eligibility and configuration in one pass, then
* build the UI registry and every option from that same resolution. Doing it the same way here is
* what makes these assertions evidence about production composition rather than a bespoke wiring.
*/
async function install(options: InstallOptions = {}): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-config-"));
temporaryDirectories.push(modulesDirectory);
for (const fixture of options.fixtures ?? [
{ configuration: announcementsConfiguration, id: "announcements" },
]) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const store = options.store ?? createInMemoryModuleConfigurationStore(options.records ?? []);
const secrets = createFakeModuleSecretSource(options.secrets ?? {});
const lifecycleStore = createInMemoryModuleLifecycleStore();
const bootSettings = await store.readSettings();
const eligibility = resolveModuleEligibility(modules, await lifecycleStore.readConfiguration(), {
secrets,
settings: bootSettings,
});
const held = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const authorizationStore = {
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(),
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: {
evaluator,
registry: composePlatformPermissionRegistry(modules),
store: authorizationStore,
},
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleConfiguration: {
bootSettings,
discoveredModules: modules,
secrets,
snapshots: eligibility.configurations,
store,
},
moduleStorage: moduleStorageOptionFor(modules),
moduleLifecycle: {
snapshot: {
discoveredModules: modules,
eligibility: eligibility.entries,
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
},
store: lifecycleStore,
},
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules, store };
}
function owner(): InstallOptions {
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
}
interface ConfigurationBody {
readonly moduleId: string;
readonly version: string;
readonly keys: readonly {
readonly key: string;
readonly type: string;
readonly required: boolean;
readonly description?: string;
readonly default?: string | number | boolean;
readonly values?: readonly string[];
readonly value?: string;
readonly configured: boolean;
}[];
readonly unknownKeys: readonly string[];
readonly restartRequired: boolean;
}
async function readConfiguration(
app: Hono,
moduleId = "announcements",
): Promise<ConfigurationBody> {
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(200);
return (await response.json()) as ConfigurationBody;
}
function putConfiguration(
app: Hono,
values: unknown,
moduleId = "announcements",
origin = trustedOrigin,
) {
return app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
body: JSON.stringify({ values }),
headers: { "content-type": "application/json", origin },
method: "PUT",
});
}
describe("GET /api/v1/module-configuration/:moduleId", () => {
it("returns the module's declared schema with defaults and stored values", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
secrets: { announcements: { storageCredential: "s3cret" } },
});
const body = await readConfiguration(app);
expect(body.moduleId).toBe("announcements");
expect(body.keys.map((entry) => entry.key)).toEqual([
"slideSeconds",
"pollIntervalSeconds",
"bucket",
"fit",
"storageCredential",
]);
expect(body.keys[0]).toEqual({
configured: false,
default: 7,
description: "How long each slide is shown.",
key: "slideSeconds",
required: false,
type: "integer",
});
expect(body.keys.find((entry) => entry.key === "bucket")).toMatchObject({
configured: true,
required: true,
value: "slides",
});
expect(body.keys.find((entry) => entry.key === "fit")?.values).toEqual(["contain", "cover"]);
});
it("never returns a secret value, only whether the deployment supplies one", async () => {
const configured = await install({
...owner(),
secrets: { announcements: { storageCredential: "s3cret" } },
});
const body = await readConfiguration(configured.app);
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
expect(secretEntry).toEqual({
configured: true,
key: "storageCredential",
required: true,
type: "secret",
});
// Not redacted, not null, not a placeholder — the field is absent entirely, and the secret
// string appears nowhere in the payload.
expect(secretEntry).not.toHaveProperty("value");
expect(secretEntry).not.toHaveProperty("default");
expect(JSON.stringify(body)).not.toContain("s3cret");
});
it("reports an unsupplied secret as not configured, still without a value field", async () => {
const { app } = await install(owner());
const body = await readConfiguration(app);
expect(body.keys.find((entry) => entry.key === "storageCredential")).toEqual({
configured: false,
key: "storageCredential",
required: true,
type: "secret",
});
});
it("lists stored keys the current manifest no longer declares, without failing", async () => {
const { app } = await install({
...owner(),
records: [
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "removedInThisVersion", moduleId: "announcements", value: "kept" },
],
});
const body = await readConfiguration(app);
expect(body.unknownKeys).toEqual(["removedInThisVersion"]);
expect(body.keys.map((entry) => entry.key)).not.toContain("removedInThisVersion");
});
it("returns a module declaring no configuration as an empty, valid document", async () => {
const { app } = await install({ ...owner(), fixtures: [{ id: "calendar" }] });
const body = await readConfiguration(app, "calendar");
expect(body.keys).toEqual([]);
expect(body.unknownKeys).toEqual([]);
expect(body.restartRequired).toBe(false);
});
it("refuses a module this process did not discover", async () => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/not-installed`, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
});
it("is not cached and varies on origin and cookie", async () => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(response.headers.get("Vary")).toContain("Cookie");
});
it("reports a sanitized 503 when the store cannot be read, with no value or driver detail", async () => {
const failing: ModuleConfigurationStore = {
readSettings: async () => {
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_READ_FAILED");
},
replace: async () => {
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_WRITE_FAILED");
},
};
let bootRead = false;
const { app } = await install({
...owner(),
store: {
readSettings: async () => {
if (!bootRead) {
bootRead = true;
return { records: [] };
}
return failing.readSettings();
},
replace: (input) => failing.replace(input),
},
});
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "MODULE_CONFIGURATION_READ_FAILED" });
});
});
describe("PUT /api/v1/module-configuration/:moduleId", () => {
it("stores values and reports that a restart is required", async () => {
const { app, store } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
});
const response = await putConfiguration(app, { bucket: "slides", slideSeconds: 12 });
expect(response.status).toBe(200);
const body = (await response.json()) as ConfigurationBody;
expect(body.keys.find((entry) => entry.key === "slideSeconds")).toMatchObject({
configured: true,
value: "12",
});
// The running process still holds the boot snapshot; claiming otherwise would be a lie.
expect(body.restartRequired).toBe(true);
expect((await store.readSettings()).records).toEqual([
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
]);
});
it("reports no pending restart when the submitted document matches what booted", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
});
const response = await putConfiguration(app, { bucket: "slides" });
expect(((await response.json()) as ConfigurationBody).restartRequired).toBe(false);
});
it("replaces the whole document, so an omitted key is cleared", async () => {
const { app, store } = await install({
...owner(),
records: [
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
],
});
await putConfiguration(app, { bucket: "slides" });
expect((await store.readSettings()).records.map((record) => record.key)).toEqual(["bucket"]);
});
it("never touches another module's stored values", async () => {
const { app, store } = await install({
...owner(),
fixtures: [
{ configuration: announcementsConfiguration, id: "announcements" },
{ configuration: [{ key: "bucket", type: "string" }], id: "calendar" },
],
records: [
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "bucket", moduleId: "calendar", value: "events" },
],
});
await putConfiguration(app, { bucket: "renamed" });
expect((await store.readSettings()).records).toEqual([
{ key: "bucket", moduleId: "announcements", value: "renamed" },
{ key: "bucket", moduleId: "calendar", value: "events" },
]);
});
it("refuses to accept secret material over HTTP", async () => {
const { app, store } = await install(owner());
const response = await putConfiguration(app, {
bucket: "slides",
storageCredential: "s3cret",
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "CONFIGURATION_SECRET_NOT_WRITABLE" });
// Nothing was written at all — not the secret, and not the valid key beside it.
expect((await store.readSettings()).records).toEqual([]);
});
it("rejects a key the module does not declare", async () => {
const { app, store } = await install(owner());
const response = await putConfiguration(app, { bucket: "slides", mqttEndpoint: "mqtt://x" });
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "CONFIGURATION_KEY_UNDECLARED" });
expect((await store.readSettings()).records).toEqual([]);
});
it.each([
["a string for an integer", { bucket: "slides", slideSeconds: "12" }],
["a float for an integer", { bucket: "slides", slideSeconds: 12.5 }],
["an integer below its declared minimum", { bucket: "slides", slideSeconds: 0 }],
["an integer above its declared maximum", { bucket: "slides", slideSeconds: 100000 }],
["a value outside a declared enum", { bucket: "slides", fit: "stretch" }],
["a string shorter than its declared minLength", { bucket: "" }],
["a nested object", { bucket: { name: "slides" } }],
["an array", { bucket: ["slides"] }],
["null", { bucket: null }],
])("rejects %s without storing anything", async (_label, values) => {
const { app, store } = await install(owner());
const response = await putConfiguration(app, values);
expect(response.status).toBe(400);
expect((await store.readSettings()).records).toEqual([]);
});
it.each([
["a body with an unexpected top-level key", { values: {}, force: true }],
["a body with no values object", {}],
["a bare array body", []],
["a bare string body", "bucket=slides"],
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
});
it.each([
"__proto__",
"constructor",
"prototype",
])("rejects the reserved property name %s as a submitted key", async (key) => {
const { app, store } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
body: `{"values":{${JSON.stringify(key)}:"polluted"}}`,
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(400);
expect((await store.readSettings()).records).toEqual([]);
// Nothing about the request reached `Object.prototype`.
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
it("accepts an incomplete document, leaving the module blocked rather than refusing the save", async () => {
const { app, store } = await install(owner());
// `bucket` is required and absent: an operator may save partial work.
const response = await putConfiguration(app, { slideSeconds: 12 });
expect(response.status).toBe(200);
expect((await store.readSettings()).records).toEqual([
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
]);
});
it("rejects an untrusted origin before authorization or the store runs", async () => {
const { app, store } = await install(owner());
const response = await putConfiguration(
app,
{ bucket: "slides" },
"announcements",
untrustedOrigin,
);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect((await store.readSettings()).records).toEqual([]);
});
it("rejects a missing origin", async () => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
body: JSON.stringify({ values: { bucket: "slides" } }),
headers: { "content-type": "application/json" },
method: "PUT",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
const { app } = await install(owner());
const oversized = JSON.stringify({
values: { bucket: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) },
});
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
body: oversized,
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
});
describe("module configuration authority", () => {
it("refuses an anonymous caller on both verbs", async () => {
const { app } = await install();
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
expect((await putConfiguration(app, { bucket: "slides" })).status).toBe(403);
});
it("refuses an ordinary authenticated user who holds no configuration permission", async () => {
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
});
it("admits a non-owner holding exactly core.modules.configuration.manage", async () => {
const { app } = await install({
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
principal: createAuthenticatedPrincipal(viewerId),
});
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(200);
});
it("does not let the lifecycle permission configure a module, or the reverse", async () => {
// The two authorities are deliberately separate: tuning how a module behaves and deciding
// whether it runs at all are different decisions (ADR 0029).
const lifecycleOnly = await install({
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
principal: createAuthenticatedPrincipal(viewerId),
});
expect(
(await lifecycleOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
).toBe(403);
expect((await lifecycleOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
const configurationOnly = await install({
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
principal: createAuthenticatedPrincipal(viewerId),
});
expect((await configurationOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(403);
expect(
(await configurationOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
).toBe(200);
});
});
describe("configuration state is visible through the lifecycle resource", () => {
it("reports a module blocked by missing required configuration, naming the key not the value", async () => {
const { app } = await install({
...owner(),
secrets: { announcements: { storageCredential: "s3cret" } },
});
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
const body = (await response.json()) as {
readonly modules: readonly {
readonly id: string;
readonly runtime: {
readonly state: string;
readonly reason?: string;
readonly configurationKey?: string;
};
}[];
};
expect(body.modules[0]?.runtime).toEqual({
configurationKey: "bucket",
reason: "configuration_missing",
state: "blocked",
});
});
it("reports a module blocked by a missing secret, without naming the secret's value", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
});
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
const raw = await response.text();
expect(raw).toContain("secret_missing");
expect(raw).toContain("storageCredential");
});
it("keeps a configuration-blocked module out of the mountable UI inventory", async () => {
const { app } = await install({
...owner(),
fixtures: [
{ configuration: [{ key: "bucket", type: "string", required: true }], id: "announcements" },
],
});
const inventory = (await (await app.request("/api/v1/module-ui")).json()) as {
readonly modules: readonly { readonly id: string }[];
};
// Blocked by its own configuration behaves exactly like blocked by a disabled dependency: it is
// not eligible, so it is not published.
expect(inventory.modules).toEqual([]);
});
});
describe("no secret or configuration value leaks through any surface", () => {
it("keeps secret material out of every response body this resource produces", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
secrets: { announcements: { storageCredential: "super-secret-value" } },
});
const listed = await (
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
headers: { origin: trustedOrigin },
})
).text();
const mutated = await (await putConfiguration(app, { bucket: "slides" })).text();
const lifecycle = await (
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
).text();
for (const payload of [listed, mutated, lifecycle]) {
expect(payload).not.toContain("super-secret-value");
}
});
it("does not serve a secret value even if a row for that key somehow exists in storage", async () => {
// Unreachable through the API, which refuses to write a secret key, and unreachable through the
// resolver, which never reads one. This plants the row directly to prove the *serialization*
// boundary holds independently: a secret's `value` is omitted by construction, not filtered by a
// branch someone could remove.
const { app } = await install({
...owner(),
records: [
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "storageCredential", moduleId: "announcements", value: "planted-in-the-database" },
],
});
const body = await readConfiguration(app);
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
expect(secretEntry).not.toHaveProperty("value");
expect(secretEntry?.configured).toBe(false);
expect(JSON.stringify(body)).not.toContain("planted-in-the-database");
});
it("never leaks a filesystem path, package name, or manifest path", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
});
const listed = await (
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
headers: { origin: trustedOrigin },
})
).text();
expect(listed).not.toContain("/tmp");
expect(listed).not.toContain("@test/module-");
expect(listed).not.toContain("ekklesia.module.json");
expect(listed).not.toContain("dist/");
});
});
describe("the configuration prefix is a sibling of the module route namespace", () => {
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
// `config` and `configuration` are ordinary lowercase static segments under
// `moduleRoutePathSchema`, so a module could legitimately register `PUT /config` and land on
// `/api/v1/modules/announcements/config`. A sibling prefix cannot collide.
expect(MODULE_CONFIGURATION_PATH.startsWith("/api/v1/modules/")).toBe(false);
expect(MODULE_CONFIGURATION_PATH).toBe("/api/v1/module-configuration");
const { app } = await install(owner());
const collision = await app.request("/api/v1/modules/announcements/config", {
body: JSON.stringify({ values: {} }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(collision.status).toBe(404);
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
});
});
@@ -0,0 +1,780 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { createApiDatabase } from "../src/database.js";
import { type RunningApi, startApi } from "../src/server.js";
/**
* Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module
* (`core/tests/fixtures/module-database-integration/db-facade/`) whose lifecycle hooks and
* permissioned HTTP handlers perform real typed Drizzle operations — insert, select, update,
* transaction, execute — against its own migrated table, exclusively through the facade the
* platform hands it. Follows the exact harness conventions `apps/api/tests/api-http.integration.ts`
* and `core/tests/postgres-migration.integration.ts` already establish (self-contained per-file
* helpers, one disposable PostgreSQL database per scenario, `describe.sequential` blocks sharing a
* running API across related assertions) rather than introducing a second integration-test
* framework or Docker orchestration path.
*
* The fixture module is committed under `core/tests/fixtures/...`, not `apps/api/tests/fixtures/`,
* for one concrete reason: its schema imports `drizzle-orm/pg-core` and `@ekklesia/contracts`,
* neither of which `@ekklesia/api` declares as a direct dependency (pnpm's strict linker will not
* resolve them from anywhere under `apps/api/`), while `@ekklesia/core` already depends on both
* directly, so any file physically located under `core/`'s directory tree resolves them correctly
* regardless of which package's test actually points discovery at it. It is never registered as a
* pnpm workspace package (it matches none of `pnpm-workspace.yaml`'s glob patterns), never sits in
* the repository's live `modules/` directory, and is only ever discovered when this file explicitly
* passes its directory as `modulesDirectoryOverride` — normal production startup, Doctor, and
* `pnpm modules:validate` never see it.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const thisDirectory = path.dirname(fileURLToPath(import.meta.url));
const coreMigrationsDirectory = path.join(thisDirectory, "..", "..", "..", "core", "drizzle");
const fixtureModulesDirectory = path.join(
thisDirectory,
"..",
"..",
"..",
"core",
"tests",
"fixtures",
"module-database-integration",
);
const RECORDS_TABLE = "db_facade_records";
const RECORDER_GLOBAL_KEY = "ekklesia.integration.db-facade.recorder";
// Must match `STARTUP_RECORD_ID` in the fixture's `server/index.js` exactly.
const STARTUP_RECORD_ID = "11111111-1111-1111-1111-111111111111";
const identityBaseUrl = "https://identity.example.test";
const dashboardOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function harnessError(message: string): IntegrationHarnessError {
return new IntegrationHarnessError(message);
}
function baseDatabaseUrl(): string {
const value = process.env[integrationDatabaseUrlEnvironment];
if (typeof value !== "string" || value.trim() === "") {
throw harnessError("The module database integration database is not configured.");
}
try {
const parsed = new URL(value);
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
throw new TypeError("Unsupported protocol.");
}
} catch {
throw harnessError("The module database integration database configuration is invalid.");
}
return value;
}
function quoteDatabaseName(databaseName: string): string {
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
throw harnessError("The generated integration database name is invalid.");
}
return `"${databaseName}"`;
}
let databaseCounter = 0;
function makeDatabaseName(label: string): string {
const safeLabel = label
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
.slice(0, 14);
databaseCounter += 1;
return `ekk_dbf_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomUUID().slice(0, 8)}`;
}
function databaseUrl(databaseName: string): string {
const parsed = new URL(baseDatabaseUrl());
parsed.pathname = `/${databaseName}`;
parsed.searchParams.delete("application_name");
return parsed.toString();
}
async function openAdministrativeClient(connectionString: string): Promise<Client> {
const client = new Client({ connectionString });
client.on("error", () => undefined);
try {
await client.connect();
return client;
} catch {
try {
await client.end();
} catch {
// The public harness failure remains static when connection cleanup also fails.
}
throw harnessError("The module database integration connection failed.");
}
}
async function safeQuery<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
const trackedDatabases = new Set<string>();
async function createDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
} finally {
await safeEnd(admin);
}
}
async function dropDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(
admin,
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
[databaseName],
);
await safeQuery(
admin,
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
);
trackedDatabases.delete(databaseName);
} finally {
await safeEnd(admin);
}
}
async function activeConnectionCount(databaseName: string): Promise<number> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
const rows = await safeQuery<{ count: string }>(
admin,
"SELECT count(*)::text AS count FROM pg_stat_activity WHERE datname = $1",
[databaseName],
);
return Number(rows[0]?.count ?? "0");
} finally {
await safeEnd(admin);
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
trackedDatabases.add(name);
return { name, url: databaseUrl(name) };
}
/** Applies only Core's own migrations, against an empty modules directory — the module fixture's
* migration is deliberately absent, used only by the "no auto-migration" negative scenario below. */
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModulesDirectory = await mkdtemp(
path.join(tmpdir(), "ekklesia-db-facade-no-modules-"),
);
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModulesDirectory, { recursive: true, force: true });
}
}
/** Applies Core's migrations plus the `db-facade` fixture module's own migration — explicitly,
* before any module runtime exists — proving the platform never infers or auto-generates this
* table. Pass-1's committed core migrations and checksums must not have drifted while this pass
* was built. */
async function applyMigrationsWithModule(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: fixtureModulesDirectory,
platformVersion: "0.1.0",
},
});
// Core migrations aggregate first, exactly once each, and always before any module migration.
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"db-facade:0000_initial",
]);
expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
);
expect(aggregate.migrations[1]?.checksum).toBe(
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
);
expect(aggregate.migrations[2]?.checksum).toBe(
"63429473118065dfe3877a1baec61b8f61663b88127d9cb17707c859f27f6036",
);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
}
async function startMigratedApi(
url: string,
modulesDirectoryOverride?: string,
): Promise<RunningApi> {
const config = createApiRuntimeConfig(
{
databaseUrl: url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
publicSignupEnabled: true,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin],
},
port: 0,
},
// Development validation: this suite signs an owner account up over HTTP to exercise real
// authorized module database access, and production configuration refuses to enable public
// signup at all until durable abuse controls exist
// (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, `core/src/identity/config.ts`). `environment`
// affects only Ekklesia's own config validation — it never reaches Better Auth — and the base
// URL and trusted origins here are identical either way.
{ environment: "development" },
);
return startApi(config, {
rateLimitOverride: { max: 1000, window: 1 },
...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }),
});
}
function apiOrigin(running: RunningApi): string {
const address = running.server.address();
if (!address || typeof address !== "object") {
throw harnessError("The API integration server has no bound address.");
}
return `http://127.0.0.1:${address.port}`;
}
interface SignUpResult {
readonly userId: string;
}
async function signUp(origin: string, email: string, password: string): Promise<SignUpResult> {
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
body: JSON.stringify({ email, name: "Integration Test User", password }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const body = (await response.json()) as { user: { id: string } };
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> {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const setCookie = response.headers.get("set-cookie");
if (!setCookie) {
throw harnessError("Sign-in did not return a session cookie.");
}
return setCookie.split(",")[0]?.split(";")[0]?.trim() ?? "";
}
interface RecordedEvent {
readonly type: "register" | "start" | "stop" | "handler";
readonly services?: { readonly database: unknown };
readonly route?: string;
readonly contextKeys?: readonly string[];
}
function globalRecord(): Record<string, unknown> {
return globalThis as unknown as Record<string, unknown>;
}
function resetRecorder(): void {
globalRecord()[RECORDER_GLOBAL_KEY] = [];
}
function readRecorder(): RecordedEvent[] {
return (globalRecord()[RECORDER_GLOBAL_KEY] as RecordedEvent[] | undefined) ?? [];
}
async function tableExists(url: string): Promise<boolean> {
const admin = await openAdministrativeClient(url);
try {
const rows = await safeQuery<{ exists: boolean }>(
admin,
"select exists (select 1 from information_schema.tables where table_schema = 'public' and table_name = $1) as exists",
[RECORDS_TABLE],
);
return rows[0]?.exists ?? false;
} finally {
await safeEnd(admin);
}
}
async function fetchRecordRow(
url: string,
id: string,
): Promise<{ id: string; value: string } | undefined> {
const admin = await openAdministrativeClient(url);
try {
const rows = await safeQuery<{ id: string; value: string }>(
admin,
`select id, value from "${RECORDS_TABLE}" where id = $1`,
[id],
);
return rows[0];
} finally {
await safeEnd(admin);
}
}
async function recordExists(url: string, id: string): Promise<boolean> {
return (await fetchRecordRow(url, id)) !== undefined;
}
async function migrationHistoryCount(url: string, identity: string): Promise<number> {
const admin = await openAdministrativeClient(url);
try {
const rows = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_migrations" where identity = $1`,
[identity],
);
return Number(rows[0]?.count ?? "0");
} finally {
await safeEnd(admin);
}
}
afterAll(async () => {
delete globalRecord()[RECORDER_GLOBAL_KEY];
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
}
});
describe.sequential("live API — module database explicit migration ownership", () => {
it("creates no table merely by applying core migrations or constructing the pool and facade — only an explicit module migration does", async () => {
const database = await createDisposableDatabase("mig_absent");
try {
expect(await tableExists(database.url)).toBe(false);
await applyCoreMigrations(database.url);
expect(await tableExists(database.url)).toBe(false);
const handle = createApiDatabase(database.url);
expect(handle.module).toBeDefined();
await handle.close();
expect(await tableExists(database.url)).toBe(false);
await applyMigrationsWithModule(database.url);
expect(await tableExists(database.url)).toBe(true);
} finally {
await dropDatabase(database.name);
}
});
it("applies the module's migration exactly once, never regenerating or reapplying it across repeated startups", async () => {
const database = await createDisposableDatabase("mig_once");
try {
await applyMigrationsWithModule(database.url);
expect(await migrationHistoryCount(database.url, "db-facade:0000_initial")).toBe(1);
const first = await startMigratedApi(database.url, fixtureModulesDirectory);
await first.shutdown();
const second = await startMigratedApi(database.url, fixtureModulesDirectory);
await second.shutdown();
expect(await migrationHistoryCount(database.url, "db-facade:0000_initial")).toBe(1);
expect(await migrationHistoryCount(database.url, "core:0000_identity_core_tables")).toBe(1);
expect(await migrationHistoryCount(database.url, "core:0001_authorization_core_tables")).toBe(
1,
);
expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe(
1,
);
expect(
await migrationHistoryCount(database.url, "core:0003_dashboard_navigation_tables"),
).toBe(1);
expect(await migrationHistoryCount(database.url, "core:0004_module_lifecycle_state")).toBe(1);
} finally {
await dropDatabase(database.name);
}
});
it("fails module startup rather than silently creating the table when the module's own migration was never applied", async () => {
const database = await createDisposableDatabase("mig_skip");
try {
await applyCoreMigrations(database.url);
resetRecorder();
// The module whose `start` failed is now rolled back through its own `stop` hook first. That
// hook also writes to the table its missing migration would have created, so it fails too —
// which is exactly the documented reason a `stop` hook must tolerate partial initialization.
// Both outcomes are reported, in order, and neither is swallowed.
await expect(startMigratedApi(database.url, fixtureModulesDirectory)).rejects.toMatchObject({
issues: [
expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "db-facade" }),
expect.objectContaining({ code: "MODULE_ROLLBACK_FAILED", moduleId: "db-facade" }),
],
});
// No schema inference, no automatic table creation as a side effect of the failed attempt.
expect(await tableExists(database.url)).toBe(false);
} finally {
await dropDatabase(database.name);
}
});
});
describe.sequential("live API — module database access through ModuleRuntimeServices", () => {
let database: { name: string; url: string };
let running: RunningApi;
let origin: string;
let ownerCookie: string;
it("starts a freshly migrated API; register receives no services; start writes the startup record through the real facade", async () => {
database = await createDisposableDatabase("db_facade");
await applyMigrationsWithModule(database.url);
resetRecorder();
running = await startMigratedApi(database.url, fixtureModulesDirectory);
origin = apiOrigin(running);
const events = readRecorder();
const registerEvents = events.filter((event) => event.type === "register");
const startEvents = events.filter((event) => event.type === "start");
expect(registerEvents).toHaveLength(1);
expect(registerEvents[0]?.services).toBeUndefined();
expect(startEvents).toHaveLength(1);
expect(startEvents[0]?.services?.database).toBeDefined();
const startupRow = await fetchRecordRow(database.url, STARTUP_RECORD_ID);
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
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");
expect(signedUp.userId).toBeDefined();
});
it("authorized read reflects real PostgreSQL state, exposes the exact scoped handler context, and the handler's database is reference-equal to start's", async () => {
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
const events = readRecorder();
const startEvent = events.find((event) => event.type === "start");
const readHandlerEvent = [...events]
.reverse()
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
// The complete handler-context surface, asserted exactly so a new field is a deliberate change
// here rather than an unnoticed one: `config` (ADR 0029), `storage` (ADR 0030), and `realtime`
// (ADR 0032) are scoped, per-module additions; everything else a module might want — a Hono
// Context, the authorization store, raw PostgreSQL, the storage provider, an endpoint or
// credential — remains unreachable.
expect(readHandlerEvent?.contextKeys).toEqual([
"config",
"moduleId",
"params",
"principal",
"realtime",
"services",
"storage",
]);
expect(readHandlerEvent?.services).toBeDefined();
expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database);
});
it("requires a trusted Origin for the module's mutating route, performs a real insert, and a later read observes it", async () => {
const id = randomUUID();
const noOrigin = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
body: JSON.stringify({ id, value: "no-origin" }),
headers: { "content-type": "application/json", cookie: ownerCookie },
method: "POST",
});
expect(noOrigin.status).toBe(403);
expect(await noOrigin.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(await recordExists(database.url, id)).toBe(false);
const created = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
body: JSON.stringify({ id, value: "created-value" }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(created.status).toBe(201);
expect(await created.json()).toEqual({ id, value: "created-value" });
const readBack = await fetch(`${origin}/api/v1/modules/db-facade/records/${id}`);
expect(readBack.status).toBe(200);
expect(await readBack.json()).toEqual({ id, value: "created-value" });
const patched = await fetch(`${origin}/api/v1/modules/db-facade/records/${id}`, {
body: JSON.stringify({ value: "patched-value" }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "PATCH",
});
expect(patched.status).toBe(200);
expect(await patched.json()).toEqual({ id, value: "patched-value" });
expect(await fetchRecordRow(database.url, id)).toEqual({ id, value: "patched-value" });
});
it("denies the mutating route to an unauthenticated caller, invoking neither the handler nor a database mutation", async () => {
const id = randomUUID();
const eventsBefore = readRecorder().length;
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
body: JSON.stringify({ id, value: "should-not-persist" }),
headers: {
"content-type": "application/json",
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(readRecorder().length).toBe(eventsBefore);
expect(await recordExists(database.url, id)).toBe(false);
});
it("rejects an untrusted-Origin mutation before principal resolution, invoking neither the handler nor a database mutation", async () => {
const id = randomUUID();
const eventsBefore = readRecorder().length;
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
body: JSON.stringify({ id, value: "should-not-persist" }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: untrustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(readRecorder().length).toBe(eventsBefore);
expect(await recordExists(database.url, id)).toBe(false);
});
it("keeps OPTIONS platform-owned: never invokes module code, never mutates the table, and returns correct final CORS/Vary", async () => {
const id = randomUUID();
const eventsBefore = readRecorder().length;
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
headers: {
"access-control-request-method": "POST",
origin: dashboardOrigin,
},
method: "OPTIONS",
});
expect(response.status).toBe(204);
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
const vary = (response.headers.get("vary") ?? "").split(",").map((token) => token.trim());
expect(vary.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
expect(readRecorder().length).toBe(eventsBefore);
expect(await recordExists(database.url, id)).toBe(false);
});
it("cannot let HEAD bypass the authorized GET chain, and creates no mutation", async () => {
const anonymousHead = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
method: "HEAD",
});
expect(anonymousHead.status).toBe(403);
expect(await anonymousHead.text()).toBe("");
const authorizedHead = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
headers: { cookie: ownerCookie },
method: "HEAD",
});
expect(authorizedHead.status).toBe(200);
expect(await authorizedHead.text()).toBe("");
const countBefore = await countRecordRowsViaGet(origin, ownerCookie);
const countAfter = await countRecordRowsViaGet(origin, ownerCookie);
expect(countAfter).toBe(countBefore);
});
it("commits a real transaction and persists its result", async () => {
const id = randomUUID();
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/transactional`, {
body: JSON.stringify({ id, value: "committed-value" }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(201);
expect(await response.json()).toEqual({ committed: true });
expect(await fetchRecordRow(database.url, id)).toEqual({ id, value: "committed-value" });
});
it("rolls back a real transaction on a unique-constraint failure, leaving no partial mutation", async () => {
const id = randomUUID();
const conflictId = randomUUID();
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/transactional`, {
body: JSON.stringify({ id, value: "should-roll-back", conflictId }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(409);
expect(await response.json()).toEqual({ committed: false });
// The whole transaction rolled back atomically — including the first, otherwise-valid insert.
expect(await recordExists(database.url, id)).toBe(false);
expect(await recordExists(database.url, conflictId)).toBe(false);
});
it("preserves a module's own sanitized response for a database failure it deliberately catches", async () => {
const response = await fetch(`${origin}/api/v1/modules/db-facade/safe-boom`);
expect(response.status).toBe(409);
expect(await response.json()).toEqual({ code: "BOOM_HANDLED" });
});
it("sanitizes an uncaught real PostgreSQL failure to the existing MODULE_HANDLER_FAILED boundary, leaking no detail", async () => {
const response = await fetch(`${origin}/api/v1/modules/db-facade/boom`);
const text = await response.text();
expect(response.status).toBe(500);
expect(text).toBe(JSON.stringify({ code: "MODULE_HANDLER_FAILED" }));
expect(response.headers.get("cache-control")).toBe("no-store");
const lowered = text.toLowerCase();
for (const forbidden of [
"division",
"sql",
"postgres",
"22012",
"select",
"relation",
"stack",
"node_modules",
fixtureModulesDirectory.toLowerCase(),
]) {
expect(lowered).not.toContain(forbidden);
}
});
it("stops cleanly: stop receives the same services and database as start, its write completes before the pool closes, and the API shuts down", async () => {
const events = readRecorder();
const startEvent = events.find((event) => event.type === "start");
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0);
const eventsAfterShutdown = readRecorder();
const stopEvents = eventsAfterShutdown.filter((event) => event.type === "stop");
expect(stopEvents).toHaveLength(1);
expect(stopEvents[0]?.services?.database).toBe(startEvent?.services?.database);
// Read via a separate administrative connection, entirely independent of the API's own
// (now-closed) pool — proving the stop write completed and persisted before that pool closed.
const stoppedRow = await fetchRecordRow(database.url, STARTUP_RECORD_ID);
expect(stoppedRow).toEqual({ id: STARTUP_RECORD_ID, value: "stopped-by-shutdown" });
await dropDatabase(database.name);
});
});
async function countRecordRowsViaGet(origin: string, cookie: string): Promise<number> {
const response = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
headers: { cookie },
});
const body = (await response.json()) as { count: number };
return body.count;
}
@@ -0,0 +1,568 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
import {
DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_PATH,
ModuleDisplayConfigurationError,
moduleDisplayDescriptorUrl,
moduleDisplayPageUrl,
} from "../src/module-display-routes.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
const trustedOrigin = "https://dashboard.example.test";
const displayPermission = "announcements.display.view";
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
`<!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.__ekklesiaDisplayWorkerLoaded = true;\n",
);
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly display?: boolean;
readonly id?: string;
readonly ui?: boolean;
readonly version?: string;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture = {}): Promise<void> {
const id = fixture.id ?? "announcements";
const version = fixture.version ?? "1.4.2";
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version,
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
...(fixture.ui ? { ui: "./dist/ui/index.js" } : {}),
...(fixture.display
? {
display: {
artifact: "./dist/display/index.js",
permission: `${id}.display.view`,
},
}
: {}),
},
id,
manifestVersion: 7,
name: `${id} module`,
permissions: fixture.display
? [
{
baseline: "restricted",
description: `View the ${id} display.`,
id: `${id}.display.view`,
},
]
: [],
version,
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };\n`,
);
if (fixture.ui) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
);
}
if (fixture.display) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/display/index.js"),
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount(container, context) { container.textContent = context.moduleId + ":slideshow"; } };\n`,
);
}
}
interface InstallOptions extends ModuleFixture {
readonly assets?: DisplayHostAssets;
readonly evaluator?: AuthorizationEvaluator;
readonly granted?: readonly string[];
readonly principal?: EkklesiaPrincipal;
/** The API builds publication from the eligible/running set. `false` models a module that was
* discovered but disabled or dependency-blocked before runtime startup. */
readonly published?: boolean;
readonly withAssets?: boolean;
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
}
function fakeServices(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
function fakeAuthorizationStore(): 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(),
} as unknown as AuthorizationStore;
}
async function install(options: InstallOptions): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-display-"));
temporaryDirectories.push(modulesDirectory);
await createModule(modulesDirectory, options);
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const granted = new Set(options.granted ?? []);
const evaluator =
options.evaluator ??
createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
platformOwners: { isPlatformOwner: async () => false },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const publishedModules = options.published === false ? [] : modules;
const moduleUi = createModuleUiPublicationRegistry(publishedModules);
const app = createApiApp({
authorization: {
evaluator,
registry: composePlatformPermissionRegistry(modules),
store: fakeAuthorizationStore(),
},
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
? { displayHostAssets: options.assets ?? displayHostAssets }
: {}),
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleRoutes: Object.freeze([]),
moduleServices: fakeServices(),
moduleStorage: moduleStorageOptionFor(modules),
moduleUi,
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules };
}
function signedIn(): EkklesiaPrincipal {
return createAuthenticatedPrincipal(viewerId);
}
describe("module display routes — authorization and descriptor", () => {
it("gives an unpaired viewer the inert shell but never the descriptor", async () => {
const { app } = await install({ display: true });
const [page, descriptor] = await Promise.all([
app.request(moduleDisplayPageUrl("announcements")),
app.request(moduleDisplayDescriptorUrl("announcements")),
]);
// The page is deliberately open: a television nobody has paired has no identity to present, and
// a 403 here would leave it nowhere to show a pairing code (ADR 0036). It carries nothing —
// the same module-agnostic bytes every display gets, with no module id and no data.
expect(page.status).toBe(200);
expect(await page.text()).toBe(displayHostAssets.html);
// Authorization is entirely in the descriptor, and it has not moved.
expect(descriptor.status).toBe(403);
expect(descriptor.headers.get("cache-control")).toBe("no-store");
expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("requires the declared display permission, not merely an authenticated principal", async () => {
const { app } = await install({ display: true, principal: signedIn() });
// Being signed in is not being permitted, and the descriptor is where that is decided.
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403);
});
it("serves the stable shell and current versioned descriptor to a permitted principal", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
version: "1.4.2",
});
const page = await app.request(moduleDisplayPageUrl("announcements"));
const descriptor = await app.request(moduleDisplayDescriptorUrl("announcements"));
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-store");
expect(page.headers.get("x-content-type-options")).toBe("nosniff");
expect(await page.text()).toBe(displayHostAssets.html);
expect(await descriptor.json()).toEqual({
viewer: { kind: "authenticated", userId: viewerId },
module: {
contributions: [],
api: { baseUrl: moduleApiBaseUrl("announcements") },
display: {
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
},
id: "announcements",
page: { url: moduleDisplayPageUrl("announcements") },
permissions: [displayPermission],
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
version: "1.4.2",
},
});
expect(descriptor.headers.get("cache-control")).toBe("no-store");
expect(descriptor.headers.get("content-type")).toContain("application/json");
expect(descriptor.headers.get("vary")).toContain("Origin");
expect(descriptor.headers.get("vary")).toContain("Cookie");
});
it("sanitizes a permission-hint evaluation failure after route authorization succeeds", async () => {
const authorize = vi
.fn()
.mockResolvedValueOnce({ allowed: true })
.mockRejectedValueOnce(new Error("postgresql://[email protected] /srv/modules/announcements"));
const evaluator: AuthorizationEvaluator = { authorize };
const { app } = await install({ display: true, evaluator, principal: signedIn() });
const response = await app.request(moduleDisplayDescriptorUrl("announcements"));
const body = await response.text();
expect(authorize).toHaveBeenCalledTimes(2);
expect(response.status).toBe(503);
expect(JSON.parse(body)).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("vary")).toContain("Origin");
expect(response.headers.get("vary")).toContain("Cookie");
for (const secret of ["postgresql", "127.0.0.1", "/srv/", "announcements"]) {
expect(body).not.toContain(secret);
}
});
it("keeps the outer page stable when the internal artifact version changes", () => {
expect(moduleDisplayPageUrl("announcements")).toBe("/display/announcements");
expect(moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.0.0" })).not.toBe(
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "2.0.0" }),
);
});
});
describe("module display routes — role and lifecycle separation", () => {
it("keeps a display-only module out of the primary UI inventory", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
});
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
expect(inventory.status).toBe(200);
expect(await inventory.json()).toEqual({ modules: [] });
});
it("does not give a dashboard-only module a display route", async () => {
const { app } = await install({ principal: signedIn(), ui: true });
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
expect(inventory.status).toBe(200);
expect((await inventory.json()) as { modules: unknown[] }).toMatchObject({
modules: [{ id: "announcements" }],
});
});
it("publishes both roles independently when a module declares both", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
ui: true,
});
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
expect(
(
(await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
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 () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
published: false,
});
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(404);
expect(
(
await app.request(
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
)
).status,
).toBe(404);
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
});
it("registers only the exact stable page and descriptor paths", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
});
for (const route of [
"/display",
"/display/announcements/",
"/display/announcements/settings",
"/api/v1/module-displays",
"/api/v1/module-displays/announcements/",
"/api/v1/module-displays/announcements/settings",
]) {
expect((await app.request(route)).status, route).toBe(404);
}
});
it("fails composition when a display exists but its host assets were not supplied", async () => {
await expect(install({ display: true, withAssets: false })).rejects.toBeInstanceOf(
ModuleDisplayConfigurationError,
);
});
});
describe("module display host asset publication", () => {
it("serves only the fixed script with JavaScript and revalidation headers", async () => {
const { app } = await install({ display: true });
const response = await app.request(DISPLAY_HOST_SCRIPT_PATH);
expect(response.status).toBe(200);
expect(await response.text()).toBe(displayHostAssets.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("x-content-type-options")).toBe("nosniff");
const notModified = await app.request(DISPLAY_HOST_SCRIPT_PATH, {
headers: { "if-none-match": displayHostAssets.scriptEtag },
});
expect(notModified.status).toBe(304);
expect(await notModified.text()).toBe("");
expect(notModified.headers.get("etag")).toBe(displayHostAssets.scriptEtag);
expect(notModified.headers.get("cache-control")).toBe("no-cache");
for (const header of [
`W/${displayHostAssets.scriptEtag}`,
"*",
`"stale", ${displayHostAssets.scriptEtag}`,
]) {
expect(
(
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
headers: { "if-none-match": header },
})
).status,
header,
).toBe(304);
}
expect(
(
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
headers: { "if-none-match": '"stale"' },
})
).status,
).toBe(200);
});
it("exposes no source map, neighboring file, arbitrary role, or request-selected path", async () => {
const { app } = await install({ display: true });
for (const candidate of [
`${DISPLAY_HOST_SCRIPT_PATH}.map`,
"/assets/ekklesia-display/index.html",
"/assets/ekklesia-display/secret.txt",
"/assets/ekklesia-display/../secret.txt",
"/api/v1/module-ui/announcements/1.4.2/management",
]) {
expect((await app.request(candidate)).status, candidate).toBe(404);
}
});
it("uses identical non-interpolated HTML for every eligible module", async () => {
const first = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
});
const second = await install({
display: true,
granted: ["signage.display.view"],
id: "signage",
principal: signedIn(),
});
const firstHtml = await (await first.app.request(moduleDisplayPageUrl("announcements"))).text();
const secondHtml = await (await second.app.request(moduleDisplayPageUrl("signage"))).text();
expect(firstHtml).toBe(secondHtml);
expect(firstHtml).not.toContain("announcements");
expect(secondHtml).not.toContain("signage");
});
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",
"export const worker = 1;\n",
);
const mutable = { ...source };
const { app } = await install({ assets: mutable, display: true });
mutable.html = "<html>mutated secret</html>";
mutable.script = "throw new Error('mutated secret');";
expect(await (await app.request(DISPLAY_HOST_SCRIPT_PATH)).text()).toBe(source.script);
await expect(
install({
assets: { ...source, scriptEtag: '"stale-validator"' },
display: true,
}),
).rejects.toBeInstanceOf(ModuleDisplayConfigurationError);
});
it("does not publish the generic host script when no eligible display uses it", async () => {
const { app } = await install({ ui: true, withAssets: false });
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);
}
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,582 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
CORE_MODULES_LIFECYCLE_MANAGE,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
type ModuleLifecycleStore,
ModuleLifecycleStoreError,
EMPTY_MODULE_REALTIME_REGISTRY,
resolveModuleEligibility,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly id: string;
readonly dependencies?: Record<string, unknown>;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 7,
name: `${fixture.id} module`,
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
),
writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
),
]);
}
const defaultFixtures: readonly ModuleFixture[] = [
{ id: "storage" },
{ id: "announcements", dependencies: { modules: { required: { storage: "^1.0.0" } } } },
];
interface InstallOptions {
readonly fixtures?: readonly ModuleFixture[];
readonly principal?: EkklesiaPrincipal;
readonly platformOwner?: boolean;
readonly bootDisabled?: readonly string[];
readonly store?: ModuleLifecycleStore;
/** Permissions this principal actually holds through a grant, evaluated by Core's real evaluator
* — never a bypass, so a delegated holder is exercised the same way a real one would be. */
readonly grants?: readonly string[];
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
readonly store: ModuleLifecycleStore;
}
/**
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
* composition: resolve eligibility from the stored configuration, then build the UI publication
* registry and the boot snapshot from the **eligible** list rather than the discovered one. Doing it
* the same way here is what makes these assertions evidence about production composition rather than
* about a bespoke test wiring.
*/
async function install(options: InstallOptions = {}): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-lifecycle-"));
temporaryDirectories.push(modulesDirectory);
for (const fixture of options.fixtures ?? defaultFixtures) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const store =
options.store ??
createInMemoryModuleLifecycleStore(
(options.bootDisabled ?? []).map((moduleId) => ({ enabled: false, moduleId })),
);
const bootConfiguration = await store.readConfiguration();
const eligibility = resolveModuleEligibility(modules, bootConfiguration);
const held = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const authorizationStore = {
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(),
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: {
evaluator,
// Permission composition still runs over the whole discovered inventory, unchanged: a
// disabled module's permissions stay registered and grantable, and nothing enforces them
// because it mounts no routes.
registry: composePlatformPermissionRegistry(modules),
store: authorizationStore,
},
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
moduleLifecycle: {
snapshot: {
discoveredModules: modules,
eligibility: eligibility.entries,
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
},
store,
},
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules, store };
}
function owner(): InstallOptions {
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
}
interface LifecycleEntry {
readonly id: string;
readonly version: string;
readonly configured: { readonly enabled: boolean; readonly explicit: boolean };
readonly runtime: {
readonly state: string;
readonly reason?: string;
readonly dependency?: { readonly kind: string; readonly moduleId: string };
};
readonly restartRequired: boolean;
}
async function readLifecycle(app: Hono): Promise<readonly LifecycleEntry[]> {
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(200);
const body = (await response.json()) as { readonly modules: readonly LifecycleEntry[] };
return body.modules;
}
function setEnabled(app: Hono, moduleId: string, enabled: boolean, origin = trustedOrigin) {
return app.request(`${MODULE_LIFECYCLE_PATH}/${moduleId}/enabled`, {
body: JSON.stringify({ enabled }),
headers: { "content-type": "application/json", origin },
method: "PUT",
});
}
describe("GET /api/v1/module-lifecycle", () => {
it("reports every discovered module with both axes separated", async () => {
const { app } = await install(owner());
expect(await readLifecycle(app)).toEqual([
{
configured: { enabled: true, explicit: false },
id: "storage",
restartRequired: false,
runtime: { state: "running" },
version: "1.0.0",
},
{
configured: { enabled: true, explicit: false },
id: "announcements",
restartRequired: false,
runtime: { state: "running" },
version: "1.0.0",
},
]);
});
it("explains a disabled module and the dependent it blocks, with a structured reason", async () => {
const { app } = await install({ ...owner(), bootDisabled: ["storage"] });
expect(await readLifecycle(app)).toEqual([
{
configured: { enabled: false, explicit: true },
id: "storage",
restartRequired: false,
runtime: { state: "disabled" },
version: "1.0.0",
},
{
configured: { enabled: true, explicit: false },
id: "announcements",
restartRequired: false,
runtime: {
dependency: { kind: "module", moduleId: "storage" },
reason: "required_dependency_disabled",
state: "blocked",
},
version: "1.0.0",
},
]);
});
it("is not cached and varies on origin and cookie", async () => {
const { app } = await install(owner());
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(response.headers.get("Vary")).toContain("Cookie");
});
it("refuses an anonymous caller", async () => {
const { app } = await install();
const response = await app.request(MODULE_LIFECYCLE_PATH);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("refuses an ordinary authenticated user who holds no lifecycle permission", async () => {
// The same principal that `core.modules.ui.read` (baseline `authenticated`) would admit: this
// resource is deliberately restricted, because it is the only place a disabled module is
// visible at all.
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
const lifecycle = await app.request(MODULE_LIFECYCLE_PATH);
expect(inventory.status).toBe(200);
expect(lifecycle.status).toBe(403);
});
it("admits a non-owner holding exactly core.modules.lifecycle.manage through a real grant", async () => {
const { app } = await install({
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
principal: createAuthenticatedPrincipal(viewerId),
});
expect((await app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
// Holding it confers no module authority: the Dashboard's own manage permission is untouched by
// this grant, so a lifecycle administrator cannot reorganize navigation.
const reorganize = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
body: JSON.stringify({ label: "Media", order: 10 }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(reorganize.status).toBe(403);
});
it("reports a sanitized 503 when the configuration cannot be read, never a driver detail", async () => {
const failing: ModuleLifecycleStore = {
readConfiguration: async () => {
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_READ_FAILED");
},
setEnabled: async () => {
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED");
},
};
// Boot with a working store, then swap in the failing one for the request path only.
const { app } = await install({ ...owner(), store: failingAfterBoot(failing) });
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "MODULE_LIFECYCLE_READ_FAILED" });
});
});
/** Answers the one boot-time read with an empty configuration, then fails every later call — so a
* request-path failure can be exercised without making startup composition itself impossible. */
function failingAfterBoot(failing: ModuleLifecycleStore): ModuleLifecycleStore {
let bootRead = false;
return {
async readConfiguration() {
if (!bootRead) {
bootRead = true;
return { modules: [] };
}
return failing.readConfiguration();
},
setEnabled: (input) => failing.setEnabled(input),
};
}
describe("PUT /api/v1/module-lifecycle/:moduleId/enabled", () => {
it("persists a decision and reports honestly that the running process has not changed", async () => {
const { app } = await install(owner());
const response = await setEnabled(app, "announcements", false);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
configured: { enabled: false, explicit: true },
id: "announcements",
// Not `disabled`: this process is still serving that module. Claiming otherwise would be a
// lie about a module that is still running.
restartRequired: true,
runtime: { state: "running" },
version: "1.0.0",
});
});
it("marks the dependents of a newly disabled module as restart-pending too", async () => {
const { app } = await install(owner());
await setEnabled(app, "storage", false);
expect((await readLifecycle(app)).map((entry) => [entry.id, entry.restartRequired])).toEqual([
["storage", true],
["announcements", true],
]);
});
it("is idempotent, and re-enabling clears the pending restart", async () => {
const { app } = await install(owner());
await setEnabled(app, "announcements", false);
await setEnabled(app, "announcements", false);
const reEnabled = await setEnabled(app, "announcements", true);
expect(await reEnabled.json()).toEqual({
configured: { enabled: true, explicit: true },
id: "announcements",
restartRequired: false,
runtime: { state: "running" },
version: "1.0.0",
});
});
it("refuses a module this process cannot see, rather than storing an unverifiable record", async () => {
const { app, store } = await install(owner());
const response = await setEnabled(app, "not-installed", false);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
expect((await store.readConfiguration()).modules).toEqual([]);
});
it("refuses a malformed module identifier without reaching the store", async () => {
const { app, store } = await install(owner());
const response = await setEnabled(app, "Not%20A%20Module", false);
expect(response.status).toBe(404);
expect((await store.readConfiguration()).modules).toEqual([]);
});
it("rejects an untrusted origin before authorization or the store runs", async () => {
const { app, store } = await install(owner());
const response = await setEnabled(app, "announcements", false, untrustedOrigin);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect((await store.readConfiguration()).modules).toEqual([]);
});
it("rejects a missing origin", async () => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
body: JSON.stringify({ enabled: false }),
headers: { "content-type": "application/json" },
method: "PUT",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("refuses an authenticated caller without the restricted permission", async () => {
const { app, store } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
const response = await setEnabled(app, "announcements", false);
expect(response.status).toBe(403);
expect((await store.readConfiguration()).modules).toEqual([]);
});
it.each([
["a non-boolean value", { enabled: "false" }],
["a missing field", {}],
["an unexpected key alongside a valid one", { enabled: false, force: true }],
["an array", []],
["a bare string", "disabled"],
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
const { app, store } = await install(owner());
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect((await store.readConfiguration()).modules).toEqual([]);
});
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
const { app } = await install(owner());
const oversized = JSON.stringify({ enabled: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) });
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
body: oversized,
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
it("never leaks a filesystem path, package name, or manifest path through either verb", async () => {
const { app } = await install(owner());
const listed = await (
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
).text();
const mutated = await (await setEnabled(app, "announcements", false)).text();
for (const payload of [listed, mutated]) {
expect(payload).not.toContain("/tmp");
expect(payload).not.toContain("@test/module-");
expect(payload).not.toContain("ekklesia.module.json");
expect(payload).not.toContain("dist/");
expect(payload).not.toContain("entrypoint");
}
});
});
describe("lifecycle state governs module UI exposure", () => {
it("keeps a disabled module out of the mountable inventory and out of Dashboard navigation", async () => {
const { app } = await install({
bootDisabled: ["announcements"],
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
readonly modules: readonly { readonly id: string }[];
};
const navigation = (await (await app.request(DASHBOARD_NAVIGATION_PATH)).json()) as {
readonly groups: readonly { readonly modules: readonly { readonly name: string }[] }[];
readonly ungrouped: readonly { readonly module: { readonly id: string } }[];
};
// Discovered, validated, and declaring a real compiled UI artifact — and still absent, because
// publication is built from the eligible set rather than the discovered one.
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage"]);
expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["storage"]);
expect(navigation.groups).toEqual([]);
});
it("keeps a dependency-blocked module out of both, exactly like an explicitly disabled one", async () => {
const { app } = await install({
bootDisabled: ["storage"],
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
readonly modules: readonly { readonly id: string }[];
};
expect(inventory.modules).toEqual([]);
});
it("does not remove a module from navigation merely because a restart is pending", async () => {
const { app } = await install({
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
await setEnabled(app, "announcements", false);
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
readonly modules: readonly { readonly id: string }[];
};
// The module really is still mounted and still serving; hiding its UI now would break a viewer
// mid-session for a change that has not taken effect. `restartRequired` is where that pending
// change is reported.
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage", "announcements"]);
});
});
describe("the lifecycle prefix is a sibling of the module route namespace", () => {
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
// `moduleRoutePathSchema` accepts `enabled` as an ordinary static segment, so a module named
// `announcements` may legitimately register `PUT /enabled` — mounted at exactly
// `/api/v1/modules/announcements/enabled`. If the platform published its lifecycle mutation
// there, registration order would decide which one answers.
expect(MODULE_LIFECYCLE_PATH.startsWith("/api/v1/modules/")).toBe(false);
expect(MODULE_LIFECYCLE_PATH).toBe("/api/v1/module-lifecycle");
const { app } = await install(owner());
const collision = await app.request("/api/v1/modules/announcements/enabled", {
body: JSON.stringify({ enabled: false }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(collision.status).toBe(404);
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
});
});
+528
View File
@@ -0,0 +1,528 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRealtimePublisher,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
createAuthorizationEvaluator,
type AuthorizationEvaluator,
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
discoverModules,
type ModuleRealtimeRegistry,
type ModuleRealtimeSubscription,
startDiscoveredModuleRuntime,
type StartedModuleRuntime,
} from "@ekklesia/core/modules";
import { type ServerType, serve } from "@hono/node-server";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { mountModuleRealtimeRoutes } from "../src/module-realtime-routes.js";
const allowedUserId = parseEkklesiaUserId("99999999-9999-4999-8999-999999999999");
const deniedUserId = parseEkklesiaUserId("77777777-7777-4777-8777-777777777777");
const stableDeliveryUrl =
"https://objects.example.test/announcements/slides/welcome.png?X-Amz-Signature=stable";
const TRANSPORT_DEADLINE_MS = 2_000;
async function withinTransportDeadline<Value>(
operation: Promise<Value>,
message: string,
onTimeout: () => void = () => undefined,
): Promise<Value> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
onTimeout();
reject(new Error(message));
}, TRANSPORT_DEADLINE_MS);
}),
]);
} finally {
clearTimeout(timer);
}
}
async function waitForTransportCondition(predicate: () => boolean, message: string): Promise<void> {
let cancelled = false;
await withinTransportDeadline(
new Promise<void>((resolve) => {
const inspect = (): void => {
if (cancelled) {
return;
}
if (predicate()) {
resolve();
return;
}
setTimeout(inspect, 5);
};
inspect();
}),
message,
() => {
cancelled = true;
},
);
}
interface FixtureControl {
readonly events: string[];
publisher?: ModuleRealtimePublisher;
}
interface LiveEvent {
readonly event: "snapshot" | "update";
readonly payload: unknown;
readonly sequence: number;
}
class LiveSseConnection {
readonly #abort: AbortController;
readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
#buffer = "";
constructor(response: Response, abort: AbortController) {
if (!response.body) {
throw new Error("Realtime integration response had no body.");
}
this.#abort = abort;
this.#reader = response.body.getReader();
}
async readEvent(): Promise<LiveEvent> {
for (;;) {
const boundary = this.#buffer.indexOf("\n\n");
if (boundary >= 0) {
const block = this.#buffer.slice(0, boundary);
this.#buffer = this.#buffer.slice(boundary + 2);
if (block.startsWith(":")) {
continue;
}
const event = block
.split("\n")
.find((line) => line.startsWith("event: "))
?.slice("event: ".length);
const data = block
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice("data: ".length);
if ((event !== "snapshot" && event !== "update") || data === undefined) {
throw new Error("Realtime integration received an invalid event.");
}
const envelope = JSON.parse(data) as { payload?: unknown; sequence?: unknown };
if (!Number.isSafeInteger(envelope.sequence) || (envelope.sequence as number) <= 0) {
throw new Error("Realtime integration received an invalid sequence.");
}
return { event, payload: envelope.payload, sequence: envelope.sequence as number };
}
const result = await this.#readWithinDeadline();
if (result.done) {
throw new Error("Realtime integration stream closed before the expected event.");
}
this.#buffer += new TextDecoder().decode(result.value, { stream: true });
}
}
async expectClosed(): Promise<void> {
const result = await this.#readWithinDeadline();
expect(result.done).toBe(true);
}
async close(): Promise<void> {
this.#abort.abort();
await withinTransportDeadline(
// Abort and cancel race by design; a rejection means one of them already released the
// transport. A deadline still propagates and fails the test.
this.#reader.cancel().catch(() => undefined),
"Realtime integration client cancellation timed out.",
);
}
async #readWithinDeadline() {
return withinTransportDeadline(
this.#reader.read(),
"Realtime integration read timed out.",
() => this.#abort.abort(),
);
}
}
async function createFixtureModule(control: FixtureControl): Promise<{
readonly directory: string;
readonly symbol: symbol;
}> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-native-realtime-"));
const moduleDirectory = path.join(directory, "announcements");
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
const symbol = Symbol.for(`ekklesia.native-realtime.${Date.now()}.${Math.random()}`);
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = control;
const expression = `globalThis[Symbol.for(${JSON.stringify(Symbol.keyFor(symbol))})]`;
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: "@test/module-announcements",
type: "module",
version: "0.1.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" },
id: "announcements",
manifestVersion: 7,
name: "Announcements fixture",
permissions: [
{
baseline: "restricted",
description: "View the announcement slideshow.",
id: "announcements.slideshow.view",
},
],
version: "0.1.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default {
id: "announcements",
version: "0.1.0",
register({ realtime }) {
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
${expression}.events.push("register");
},
start({ realtime }) {
${expression}.publisher = realtime;
realtime.publish("state", {
mode: "on",
currentSlide: 0,
previousSlide: -1,
slides: [${JSON.stringify(stableDeliveryUrl)}]
});
${expression}.events.push("start");
},
stop() { ${expression}.events.push("stop"); }
};\n`,
),
]);
return { directory, symbol };
}
function services(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
}
function identityRuntime(): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn(),
resolvePrincipal: async (headers) => {
const cookie = headers.get("cookie");
if (cookie === "ekklesia-session=allowed") {
return createAuthenticatedPrincipal(allowedUserId);
}
if (cookie === "ekklesia-session=denied") {
return createAuthenticatedPrincipal(deniedUserId);
}
return ANONYMOUS_PRINCIPAL;
},
};
}
function authorization(): AuthorizationEvaluator {
return createAuthorizationEvaluator({
grants: { hasActiveGrant: async ({ userId }) => userId === allowedUserId },
platformOwners: { isPlatformOwner: async () => false },
});
}
async function bindLiveServer(
runtime: StartedModuleRuntime,
registry: PermissionRegistry,
): Promise<{
readonly activeSubscriptions: () => number;
readonly origin: string;
readonly server: ServerType;
}> {
const app = new Hono();
let activeSubscriptions = 0;
const observedRealtime: ModuleRealtimeRegistry = {
channels: runtime.realtime.channels,
closeSubscriptions: () => runtime.realtime.closeSubscriptions(),
publisherFor: (moduleId) => runtime.realtime.publisherFor(moduleId),
subscribe: (moduleId, channel): ModuleRealtimeSubscription => {
const subscription = runtime.realtime.subscribe(moduleId, channel);
activeSubscriptions += 1;
let closed = false;
return Object.freeze({
close: (): void => {
if (!closed) {
closed = true;
activeSubscriptions -= 1;
}
subscription.close();
},
read: () => subscription.read(),
snapshot: subscription.snapshot,
});
},
};
mountModuleRealtimeRoutes(app, {
evaluator: authorization(),
realtime: observedRealtime,
registry,
runtime: identityRuntime(),
});
app.notFound((c) => c.json({ code: "NOT_FOUND" }, 404));
let pendingServer: ServerType | undefined;
const server = await withinTransportDeadline(
new Promise<ServerType>((resolve, reject) => {
pendingServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, () =>
resolve(pendingServer as ServerType),
);
pendingServer.once("error", reject);
}),
"Realtime integration server bind timed out.",
() => pendingServer?.close(),
);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Realtime integration server did not bind a TCP port.");
}
return {
activeSubscriptions: () => activeSubscriptions,
origin: `http://127.0.0.1:${address.port}`,
server,
};
}
async function connect(
origin: string,
cookie: string,
): Promise<{
readonly connection: LiveSseConnection;
readonly response: Response;
}> {
const abort = new AbortController();
const response = await withinTransportDeadline(
fetch(`${origin}/api/v1/module-realtime/announcements/state`, {
headers: { cookie },
signal: abort.signal,
}),
"Realtime integration connection timed out.",
() => abort.abort(),
);
return { connection: new LiveSseConnection(response, abort), response };
}
async function closeServer(server: ServerType): Promise<void> {
await withinTransportDeadline(
new Promise<void>((resolve) => server.close(() => resolve())),
"Realtime integration server close timed out.",
() =>
(
server as ServerType & {
closeAllConnections?: () => void;
}
).closeAllConnections?.(),
);
}
async function requestJson(
url: string,
init?: RequestInit,
): Promise<{ readonly body: unknown; readonly response: Response }> {
const abort = new AbortController();
const operation = (async (): Promise<{ readonly body: unknown; readonly response: Response }> => {
const response = await fetch(url, { ...init, signal: abort.signal });
const body = (await response.json()) as unknown;
return { body, response };
})();
return withinTransportDeadline(operation, "Realtime integration finite request timed out.", () =>
abort.abort(),
);
}
describe("native realtime over a live Node HTTP server", () => {
it("loads a real module and proves auth, snapshot, fanout, update, reconnect, and shutdown", async () => {
const control: FixtureControl = { events: [] };
const fixture = await createFixtureModule(control);
let runtime: StartedModuleRuntime | undefined;
let server: ServerType | undefined;
const connections: LiveSseConnection[] = [];
const failures: unknown[] = [];
try {
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory: fixture.directory,
platformVersion: "0.1.0",
});
runtime = await startDiscoveredModuleRuntime(modules, services());
expect(control.events).toEqual(["register", "start"]);
const live = await bindLiveServer(runtime, composePlatformPermissionRegistry(modules));
server = live.server;
const anonymous = await requestJson(
`${live.origin}/api/v1/module-realtime/announcements/state`,
);
expect(anonymous.response.status).toBe(403);
expect(anonymous.body).toEqual({ code: "AUTHORIZATION_DENIED" });
const missingPermission = await requestJson(
`${live.origin}/api/v1/module-realtime/announcements/state`,
{ headers: { cookie: "ekklesia-session=denied" } },
);
expect(missingPermission.response.status).toBe(403);
const first = await connect(live.origin, "ekklesia-session=allowed");
const second = await connect(live.origin, "ekklesia-session=allowed");
connections.push(first.connection, second.connection);
expect(first.response.status).toBe(200);
expect(first.response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
const expectedInitial = {
currentSlide: 0,
mode: "on",
previousSlide: -1,
slides: [stableDeliveryUrl],
};
expect(await first.connection.readEvent()).toEqual({
event: "snapshot",
payload: expectedInitial,
sequence: 1,
});
expect(await second.connection.readEvent()).toEqual({
event: "snapshot",
payload: expectedInitial,
sequence: 1,
});
expect(live.activeSubscriptions()).toBe(2);
control.publisher?.publish("state", {
currentSlide: 1,
mode: "on",
previousSlide: 0,
slides: [stableDeliveryUrl],
});
const expectedUpdate = {
event: "update",
payload: {
currentSlide: 1,
mode: "on",
previousSlide: 0,
slides: [stableDeliveryUrl],
},
sequence: 2,
};
expect(await first.connection.readEvent()).toEqual(expectedUpdate);
expect(await second.connection.readEvent()).toEqual(expectedUpdate);
await first.connection.close();
await waitForTransportCondition(
() => live.activeSubscriptions() === 1,
"Realtime integration subscriber cleanup timed out.",
);
control.publisher?.publish("state", {
currentSlide: 2,
mode: "off",
previousSlide: 1,
slides: [stableDeliveryUrl],
});
expect(await second.connection.readEvent()).toMatchObject({ event: "update", sequence: 3 });
const reconnected = await connect(live.origin, "ekklesia-session=allowed");
connections.push(reconnected.connection);
expect(await reconnected.connection.readEvent()).toEqual({
event: "snapshot",
payload: {
currentSlide: 2,
mode: "off",
previousSlide: 1,
slides: [stableDeliveryUrl],
},
sequence: 3,
});
expect(live.activeSubscriptions()).toBe(2);
const secondClosed = second.connection.expectClosed();
const reconnectedClosed = reconnected.connection.expectClosed();
runtime.realtime.closeSubscriptions();
await Promise.all([secondClosed, reconnectedClosed]);
await waitForTransportCondition(
() => live.activeSubscriptions() === 0,
"Realtime integration shutdown cleanup timed out.",
);
// Stream shutdown happens before ordinary HTTP drain in production. A publisher must remain
// usable until that drain completes so an already-running command can publish its result.
expect(() => control.publisher?.publish("state", null)).not.toThrow();
const afterShutdown = await requestJson(
`${live.origin}/api/v1/module-realtime/announcements/state`,
{ headers: { cookie: "ekklesia-session=allowed" } },
);
expect(afterShutdown.response.status).toBe(503);
expect(afterShutdown.body).toEqual({ code: "REALTIME_UNAVAILABLE" });
await runtime.stop();
expect(() => control.publisher?.publish("state", null)).toThrowError(
expect.objectContaining({ code: "REALTIME_PUBLISHER_STOPPED" }),
);
} catch (error) {
failures.push(error);
} finally {
try {
runtime?.realtime.closeSubscriptions();
} catch (error) {
failures.push(error);
}
const connectionCleanup = await Promise.allSettled(
connections.map((connection) => connection.close()),
);
for (const result of connectionCleanup) {
if (result.status === "rejected") {
failures.push(result.reason);
}
}
if (server) {
try {
await closeServer(server);
} catch (error) {
failures.push(error);
}
}
if (runtime) {
try {
await runtime.stop();
} catch (error) {
failures.push(error);
}
}
delete (globalThis as unknown as Record<PropertyKey, unknown>)[fixture.symbol];
try {
await rm(fixture.directory, { force: true, recursive: true });
} catch (error) {
failures.push(error);
}
}
if (failures.length > 0) {
throw failures[0];
}
expect(control.events).toEqual(["register", "start", "stop"]);
});
});
@@ -0,0 +1,270 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
ModuleRealtimeError,
moduleIdSchema,
parseEkklesiaUserId,
parsePermissionId,
} from "@ekklesia/contracts";
import { createPermissionRegistry, type PermissionRegistry } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { type ModuleRealtimeRegistry, StagedModuleRealtimeRegistry } from "@ekklesia/core/modules";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import {
MODULE_REALTIME_KEEPALIVE_MS,
MODULE_REALTIME_MAX_CONNECTION_AGE_MS,
ModuleRealtimeConfigurationError,
moduleRealtimeBaseUrl,
mountModuleRealtimeRoutes,
} from "../src/module-realtime-routes.js";
const moduleId = moduleIdSchema.parse("announcements");
const permission = parsePermissionId("announcements.slideshow.view");
const userId = parseEkklesiaUserId("88888888-8888-4888-8888-888888888888");
const endpoint = "/api/v1/module-realtime/announcements/state";
function permissionRegistry(): PermissionRegistry {
return createPermissionRegistry([
{
baseline: "restricted",
description: "View the announcement slideshow.",
id: permission,
},
]);
}
function realtimeFixture(initialPayload: unknown = { mode: "on" }): {
readonly publisher: ReturnType<StagedModuleRealtimeRegistry["publisherFor"]>;
readonly realtime: ModuleRealtimeRegistry;
} {
const staged = new StagedModuleRealtimeRegistry();
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
registration.realtime.channel({ channel: "state", permission });
registration.commit();
const publisher = staged.publisherFor(moduleId);
publisher.publish("state", initialPayload as never);
return { publisher, realtime: staged.publish() };
}
function appFor(
realtime: ModuleRealtimeRegistry,
options: {
readonly allowed?: boolean;
readonly keepaliveMs?: number;
readonly maxConnectionAgeMs?: number;
readonly signedIn?: boolean;
} = {},
): Hono {
const app = new Hono();
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi
.fn()
.mockResolvedValue(
options.signedIn === false ? ANONYMOUS_PRINCIPAL : createAuthenticatedPrincipal(userId),
),
};
mountModuleRealtimeRoutes(app, {
evaluator: {
authorize: vi
.fn()
.mockResolvedValue({ allowed: options.signedIn !== false && options.allowed !== false }),
},
...(options.keepaliveMs === undefined ? {} : { keepaliveMs: options.keepaliveMs }),
...(options.maxConnectionAgeMs === undefined
? {}
: { maxConnectionAgeMs: options.maxConnectionAgeMs }),
realtime,
registry: permissionRegistry(),
runtime,
});
return app;
}
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
reader.read(),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Realtime test read timed out.")), 1_000);
}),
]);
return result.done ? "" : new TextDecoder().decode(result.value);
} finally {
clearTimeout(timer);
}
}
describe("module realtime HTTP transport", () => {
it("builds one server-owned root and rejects an unenforceable channel snapshot", () => {
expect(moduleRealtimeBaseUrl("announcements")).toBe("/api/v1/module-realtime/announcements");
expect(MODULE_REALTIME_KEEPALIVE_MS).toBe(15_000);
expect(MODULE_REALTIME_MAX_CONNECTION_AGE_MS).toBe(300_000);
const { realtime } = realtimeFixture();
expect(() =>
mountModuleRealtimeRoutes(new Hono(), {
evaluator: { authorize: vi.fn() },
realtime,
registry: createPermissionRegistry([]),
runtime: { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn() },
}),
).toThrow(ModuleRealtimeConfigurationError);
});
it("denies anonymous and unauthorized callers before allocating a subscription", async () => {
const { realtime } = realtimeFixture();
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
const observed: ModuleRealtimeRegistry = {
channels: realtime.channels,
closeSubscriptions: () => realtime.closeSubscriptions(),
publisherFor: (owner) => realtime.publisherFor(owner),
subscribe,
};
const anonymous = await appFor(observed, { signedIn: false }).request(endpoint);
expect(anonymous.status).toBe(403);
expect(await anonymous.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(subscribe).not.toHaveBeenCalled();
const unauthorized = await appFor(observed, { allowed: false }).request(endpoint);
expect(unauthorized.status).toBe(403);
expect(await unauthorized.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(subscribe).not.toHaveBeenCalled();
});
it("refuses Hono's implicit HEAD handling without allocating a stream subscription", async () => {
const { realtime } = realtimeFixture();
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
const observed: ModuleRealtimeRegistry = {
channels: realtime.channels,
closeSubscriptions: () => realtime.closeSubscriptions(),
publisherFor: (owner) => realtime.publisherFor(owner),
subscribe,
};
const response = await appFor(observed).request(endpoint, { method: "HEAD" });
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(subscribe).not.toHaveBeenCalled();
});
it("opens an authorized snapshot-first stream with proxy-safe headers and unchanged URLs", async () => {
const deliveryUrl =
"https://objects.example.test/slides/welcome.png?X-Amz-Signature=byte-identical";
const { realtime } = realtimeFixture({
currentSlide: 0,
mode: "on",
previousSlide: -1,
slides: [deliveryUrl],
});
const response = await appFor(realtime).request(endpoint);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("x-accel-buffering")).toBe("no");
expect(response.headers.get("vary")).toBe("Origin, Cookie");
const reader = response.body?.getReader();
expect(reader).toBeDefined();
const snapshot = await readChunk(reader as ReadableStreamDefaultReader<Uint8Array>);
expect(snapshot).toContain("event: snapshot\n");
expect(snapshot).toContain('"sequence":1');
expect(snapshot).toContain(deliveryUrl);
expect(snapshot).not.toContain("\nid:");
await reader?.cancel();
});
it("streams ordered updates, emits transport-only keepalives, and closes on shutdown", async () => {
const { publisher, realtime } = realtimeFixture({ currentSlide: 0 });
const response = await appFor(realtime, { keepaliveMs: 5 }).request(endpoint);
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
expect(await readChunk(reader)).toContain("event: snapshot");
publisher.publish("state", { currentSlide: 1 });
const update = await readChunk(reader);
expect(update).toContain("event: update");
expect(update).toContain('"sequence":2');
expect(update).toContain('"currentSlide":1');
expect(await readChunk(reader)).toBe(": keepalive\n\n");
const closed = reader.read();
realtime.closeSubscriptions();
await expect(closed).resolves.toEqual({ done: true, value: undefined });
});
it("closes a stream at the host-owned maximum age so reconnect reauthorizes", async () => {
const { realtime } = realtimeFixture({ currentSlide: 0 });
let activeSubscriptions = 0;
const observed: ModuleRealtimeRegistry = {
channels: realtime.channels,
closeSubscriptions: () => realtime.closeSubscriptions(),
publisherFor: (owner) => realtime.publisherFor(owner),
subscribe: (owner, channel) => {
const subscription = realtime.subscribe(owner, channel);
activeSubscriptions += 1;
let closed = false;
return Object.freeze({
close: () => {
if (!closed) {
closed = true;
activeSubscriptions -= 1;
}
subscription.close();
},
read: () => subscription.read(),
snapshot: subscription.snapshot,
});
},
};
const response = await appFor(observed, {
keepaliveMs: 1_000,
maxConnectionAgeMs: 25,
}).request(endpoint);
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
expect(await readChunk(reader)).toContain("event: snapshot");
expect(activeSubscriptions).toBe(1);
expect(await readChunk(reader)).toBe("");
expect(activeSubscriptions).toBe(0);
const replacement = realtime.subscribe(moduleId, "state");
replacement.close();
});
it("does not expose unknown module/channel combinations", async () => {
const { realtime } = realtimeFixture();
const app = appFor(realtime);
expect((await app.request("/api/v1/module-realtime/calendar/state")).status).toBe(404);
expect((await app.request("/api/v1/module-realtime/announcements/other")).status).toBe(404);
});
it("maps subscriber-capacity and unexpected allocation failures to fixed non-secret responses", async () => {
const { realtime } = realtimeFixture();
const observed = (failure: unknown): ModuleRealtimeRegistry => ({
channels: realtime.channels,
closeSubscriptions: () => realtime.closeSubscriptions(),
publisherFor: (owner) => realtime.publisherFor(owner),
subscribe: () => {
throw failure;
},
});
const atCapacity = await appFor(
observed(new ModuleRealtimeError("REALTIME_SUBSCRIBER_LIMIT")),
).request(endpoint);
expect(atCapacity.status).toBe(503);
expect(await atCapacity.json()).toEqual({ code: "REALTIME_SUBSCRIBER_LIMIT" });
const detail = "secret-token-and-machine-path";
const unavailable = await appFor(observed(new Error(detail))).request(endpoint);
expect(unavailable.status).toBe(503);
const body = await unavailable.text();
expect(body).toBe('{"code":"REALTIME_UNAVAILABLE"}');
expect(body).not.toContain(detail);
});
});
+779
View File
@@ -0,0 +1,779 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModules,
startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules";
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 {
bootstrapDisplayHost,
bootstrapHost,
createModuleUiContext,
type EkklesiaHostConfig,
moduleUiSourceUrl,
} from "@ekklesia/shell";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
/**
* The whole chain, with nothing between the halves faked:
*
* ```text
* module fixture on disk -> discovery -> module runtime started -> publication registry
* -> inventory endpoint -> the shell's own bootstrap -> server-produced artifact URL
* -> the generic loader -> mount -> the module calls its own API relatively
* -> the shell resolves it to /api/v1/modules/<id>/... -> server permission enforcement
* ```
*
* Every step runs real code. The module is discovered and *started* by Core's real runtime, its
* routes are mounted by the real `createApiApp`, its artifact is published by the real ADR 0025
* route, its descriptor comes from the real inventory endpoint, and the browser half is
* `@ekklesia/shell`'s own exported protocol — the same functions the React shell calls — rather
* than a reimplementation of them here.
*
* Two things are deliberately *not* faked-around and are worth naming. The shell's `fetch` is the
* Hono app's own `request`, so requests really traverse routing, CORS, authorization middleware, and
* handlers without needing a socket. And Node cannot `import()` an `http:` URL, so the artifact is
* fetched over that same in-process HTTP path and the **bytes it returned** are handed to the
* generic loader through a `data:` URL — a genuine module URL, the same separation ADR 0025 used.
* No test here reads the artifact off the filesystem and calls that a proof of publication.
*/
const viewerId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
/**
* A real, complete module: a manifest declaring a restricted permission and a UI entrypoint, a
* server entrypoint that registers a permission-gated route, and a compiled UI artifact that reads
* its host context and calls its own API relatively.
*
* The UI artifact is written as plain ESM rather than compiled from TSX here: ADR 0023's pipeline is
* already proven end to end by `templates/module`'s own tests, and what this test needs is a byte
* sequence whose journey through publication, inventory, and the loader can be asserted exactly.
*/
async function createFixtureModule(
modulesDirectory: string,
id = "example",
name = "Example module",
withDisplay = false,
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
if (withDisplay) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
}
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version: "0.1.0",
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
ui: "./dist/ui/index.js",
...(withDisplay
? {
display: {
artifact: "./dist/display/index.js",
permission: `${id}.display.view`,
},
}
: {}),
},
id,
manifestVersion: 7,
name,
permissions: [
{
baseline: "restricted",
description: "Read example records.",
id: `${id}.records.read`,
},
...(withDisplay
? [
{
baseline: "restricted",
description: "View the example display.",
id: `${id}.display.view`,
},
]
: []),
],
version: "0.1.0",
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default {
id: ${JSON.stringify(id)},
version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/records",
permission: ${JSON.stringify(`${id}.records.read`)},
maxBodyBytes: 0,
async handler() {
return Response.json([{ id: "a" }, { id: "b" }]);
},
});
},
};\n`,
);
// The module UI: host-neutral, asks for a relative path, and renders from the host context. It
// hardcodes no origin, no prefix, and no permission decision of its own.
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)},
version: "0.1.0",
async mount(container, context) {
if (!context.permissions.has(${JSON.stringify(`${id}.records.read`)})) {
container.textContent = context.moduleId + ":denied";
return () => { container.textContent = ""; };
}
const response = await context.api.request("records");
const records = response.ok ? await response.json() : [];
container.textContent =
context.moduleId + ":" + response.status + ":" + records.length;
return () => { container.textContent = ""; };
},
};\n`,
);
if (withDisplay) {
await writeFile(
path.join(moduleDirectory, "dist/display/index.js"),
`export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)},
version: "0.1.0",
async mount(container, context) {
const response = await context.api.request("records");
const records = response.ok ? await response.json() : [];
container.textContent =
context.moduleId + ":display:" + response.status + ":" + records.length;
return () => { container.textContent = ""; };
},
};\n`,
);
}
}
interface HarnessOptions {
readonly display?: boolean;
readonly principal?: EkklesiaPrincipal;
readonly grants?: readonly string[];
/** Extra fixture modules, so grouping can be exercised with more than one — a folder containing a
* single module proves very little. */
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
readonly platformOwner?: boolean;
}
interface Harness {
readonly config: EkklesiaHostConfig;
readonly moduleApiCalls: readonly string[];
stop(): Promise<void>;
}
/**
* Starts a real API over a real discovered-and-started module, and returns the `EkklesiaHostConfig`
* a browser would use — with `fetch` wired to the app itself.
*/
async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
temporaryDirectories.push(modulesDirectory);
await createFixtureModule(modulesDirectory, "example", "Example module", options.display);
for (const extra of options.extraModules ?? []) {
await createFixtureModule(modulesDirectory, extra.id, extra.name);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const services: ModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
});
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
// route snapshot below is the one it actually published.
const moduleRuntime = await startDiscoveredModuleRuntime(modules, services);
const granted = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const store = {
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(),
} as unknown as AuthorizationStore;
const moduleUi = createModuleUiPublicationRegistry(modules);
const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
moduleRoutes: moduleRuntime.routes,
moduleRealtime: moduleRuntime.realtime,
moduleServices: services,
moduleUi,
...(moduleUi.listDisplays().length === 0
? {}
: {
displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
"export const displayHost = true;\n",
"export const displayWorker = true;\n",
),
}),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime,
trustedOrigins: ["https://dashboard.example.test"],
});
const moduleApiCalls: string[] = [];
const hostFetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const url = new URL(String(input));
if (url.pathname.startsWith("/api/v1/modules/")) {
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`);
}
return app.request(url.href, init);
}) as unknown as typeof fetch;
return {
config: { apiBaseUrl: "https://ekklesia.example.test", fetch: hostFetch },
moduleApiCalls,
stop: () => moduleRuntime.stop(),
};
}
/** Node cannot `import()` an `http:` URL, so the artifact is fetched over the real route and its
* exact bytes become a `data:` URL. The loader still performs a genuine dynamic import of a real
* module URL; only the transport differs, and the transport itself is asserted separately. */
async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promise<string> {
const response = await (harness.config.fetch as typeof fetch)(sourceUrl);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
const bytes = Buffer.from(await response.arrayBuffer());
return `data:text/javascript;base64,${bytes.toString("base64")}`;
}
/**
* A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
* test environment.
*
* 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"];
describe("end to end: a discovered module's UI mounts in the Ekklesia host", () => {
it("bootstraps, publishes, loads, mounts, and reaches the module's own API", async () => {
const harness = await startHarness({
grants: ["example.records.read"],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
// 1. The shell's own bootstrap: principal, then inventory.
const bootstrap = await bootstrapHost(harness.config);
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: viewerId });
// A module nobody has arranged yet is ungrouped and visible — no initialization write.
expect(bootstrap.navigation.groups).toEqual([]);
expect(bootstrap.navigation.ungrouped).toHaveLength(1);
expect(bootstrap.navigation.ungrouped[0]?.name).toBe("Example module");
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
if (!descriptor) {
throw new Error("the inventory returned no module");
}
// 2. The URLs came from the server, not from anything the browser composed.
expect(descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example",
contributions: [],
id: "example",
permissions: ["example.records.read"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
uiUrl: "/api/v1/module-ui/example/0.1.0",
version: "0.1.0",
});
// 3. The artifact really is served by the real publication route at that URL.
const sourceUrl = moduleUiSourceUrl(harness.config, descriptor);
expect(sourceUrl).toBe("https://ekklesia.example.test/api/v1/module-ui/example/0.1.0");
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
// 4. The generic loader mounts it with the context the shell built.
const container = containerSink();
const mounted = await loadModuleUi({
container: container as unknown as LoaderContainer,
context: createModuleUiContext({
config: harness.config,
descriptor,
principal: bootstrap.principal,
}),
source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version },
});
// 5. The module rendered, having reached its own backend through the host.
expect(mountedText(container)).toBe("example:200:2");
// 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"]);
// 7. And it tears down cleanly.
await mounted.unmount();
expect(mountedText(container)).toBe("");
expect(mounted.mounted).toBe(false);
} finally {
await harness.stop();
}
});
it("opens the stable display route, resolves its real descriptor, and mounts the published display bytes", async () => {
const harness = await startHarness({
display: true,
grants: ["example.records.read", "example.display.view"],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
// 1. A device keeps this version-independent host URL. It is a real permission-gated API route,
// not a test-only HTML wrapper around the browser functions below.
const page = await (harness.config.fetch as typeof fetch)(
"https://ekklesia.example.test/display/example",
);
expect(page.status).toBe(200);
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(await page.text()).toContain("/assets/ekklesia-display/app.js");
// 2. The display surface's own bootstrap resolves principal first, then the singular protected
// descriptor. The browser constructs no artifact, API, or realtime URL itself.
const bootstrap = await bootstrapDisplayHost(harness.config, "example");
expect(bootstrap.status).toBe("ready");
if (bootstrap.status !== "ready") {
throw new Error("the authenticated display did not resolve");
}
expect(bootstrap.descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example",
contributions: [],
displayPageUrl: "/display/example",
id: "example",
permissions: ["example.records.read", "example.display.view"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
uiUrl: "/api/v1/module-ui/example/0.1.0/display",
version: "0.1.0",
});
// 3. Fetch the exact role-specific bytes from the real publication route, then genuinely import
// those returned bytes. Reading the fixture file directly would not prove publication.
const sourceUrl = moduleUiSourceUrl(harness.config, bootstrap.descriptor);
expect(sourceUrl).toBe(
"https://ekklesia.example.test/api/v1/module-ui/example/0.1.0/display",
);
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
// 4. The generic loader receives the host-owned browser context, including the canonical
// display page, and mounts the display artifact independently of the Dashboard artifact.
const context = createModuleUiContext({
config: harness.config,
descriptor: bootstrap.descriptor,
principal: bootstrap.principal,
});
expect(Object.keys(context).sort()).toEqual([
"api",
"contributions",
"display",
"moduleId",
"permissions",
"principal",
"realtime",
]);
const container = containerSink();
const mounted = await loadModuleUi({
container: container as unknown as LoaderContainer,
context,
source: {
moduleId: bootstrap.descriptor.id,
url: artifactUrl,
version: bootstrap.descriptor.version,
},
});
expect(mountedText(container)).toBe("example:display:200:2");
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
await mounted.unmount();
expect(mountedText(container)).toBe("");
expect(mounted.mounted).toBe(false);
} finally {
await harness.stop();
}
});
it("keeps server authorization authoritative when the UI ignores its own hint", async () => {
// Signed in, but holding no grant for the module's restricted permission.
const harness = await startHarness({
grants: [],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
const bootstrap = await bootstrapHost(harness.config);
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
if (!descriptor) {
throw new Error("the inventory returned no module");
}
// The hint is honest about it...
expect(descriptor.permissions).toEqual([]);
const context = createModuleUiContext({
config: harness.config,
descriptor,
principal: bootstrap.principal,
});
expect(context.permissions.has("example.records.read")).toBe(false);
// ...and the hint is not what protects the route. Calling anyway — exactly what a buggy or
// hostile UI would do — is still refused by the server.
const response = await context.api.request("records");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
} finally {
await harness.stop();
}
});
it("renders the module's own denied state from the hint, without calling the API", async () => {
const harness = await startHarness({
grants: [],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
const bootstrap = await bootstrapHost(harness.config);
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
if (!descriptor) {
throw new Error("the inventory returned no module");
}
const artifactUrl = await publishedArtifactUrl(
harness,
moduleUiSourceUrl(harness.config, descriptor),
);
const container = containerSink();
await loadModuleUi({
container: container as unknown as LoaderContainer,
context: createModuleUiContext({
config: harness.config,
descriptor,
principal: bootstrap.principal,
}),
source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version },
});
// Hiding a control is a usability improvement: the module simply did not ask.
expect(mountedText(container)).toBe("example:denied");
expect(harness.moduleApiCalls).toEqual([]);
} finally {
await harness.stop();
}
});
it("renders a configured arrangement, and a move changes presentation only", async () => {
const harness = await startHarness({
extraModules: [
{ id: "video", name: "Video" },
{ id: "calendar", name: "Calendar" },
],
grants: ["example.records.read"],
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
const organize = (url: string, body: unknown) =>
(harness.config.fetch as typeof fetch)(`https://ekklesia.example.test${url}`, {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: "https://dashboard.example.test" },
method: "PUT",
});
try {
// An administrator organizes the Dashboard, through the real mutation API.
await organize("/api/v1/dashboard/navigation/groups/media", { label: "Media", order: 10 });
await organize("/api/v1/dashboard/navigation/groups/planning", {
label: "Planning",
order: 20,
});
await organize("/api/v1/dashboard/navigation/placements/video", {
groupId: "media",
order: 10,
});
await organize("/api/v1/dashboard/navigation/placements/example", {
groupId: "media",
order: 20,
});
await organize("/api/v1/dashboard/navigation/placements/calendar", {
groupId: "planning",
order: 10,
});
// The shell's own bootstrap sees exactly that arrangement.
const before = await bootstrapHost(harness.config);
expect(
before.navigation.groups.map((group) => [
group.label,
group.modules.map((entry) => entry.name),
]),
).toEqual([
["Media", ["Video", "Example module"]],
["Planning", ["Calendar"]],
]);
expect(before.navigation.ungrouped).toEqual([]);
// Mounting from inside a group uses the descriptor the group carried, untouched.
const groupedEntry = before.navigation.groups[0]?.modules[1];
if (!groupedEntry) {
throw new Error("the arrangement did not contain the expected module");
}
const container = containerSink();
const mounted = await loadModuleUi({
container: container as unknown as LoaderContainer,
context: createModuleUiContext({
config: harness.config,
descriptor: groupedEntry.descriptor,
principal: before.principal,
}),
source: {
moduleId: groupedEntry.descriptor.id,
url: await publishedArtifactUrl(
harness,
moduleUiSourceUrl(harness.config, groupedEntry.descriptor),
),
version: groupedEntry.descriptor.version,
},
});
expect(mountedText(container)).toBe("example:200:2");
await mounted.unmount();
// Now move it somewhere else entirely.
await organize("/api/v1/dashboard/navigation/groups/sunday-production", {
label: "Sunday Production",
order: 5,
});
await organize("/api/v1/dashboard/navigation/placements/example", {
groupId: "sunday-production",
order: 10,
});
const after = await bootstrapHost(harness.config);
expect(
after.navigation.groups.map((group) => [
group.label,
group.modules.map((entry) => entry.name),
]),
).toEqual([
["Sunday Production", ["Example module"]],
["Media", ["Video"]],
["Planning", ["Calendar"]],
]);
const movedEntry = after.navigation.groups[0]?.modules[0];
if (!movedEntry) {
throw new Error("the moved module was not found in its new group");
}
// The whole point of the pass: the module is byte-identical across the move — same artifact
// URL, same API root, same permissions, same version, same name.
expect(movedEntry).toEqual(groupedEntry);
// And it still mounts the same artifact and reaches the same API.
const callsBeforeRemount = harness.moduleApiCalls.length;
const movedContainer = containerSink();
const remounted = await loadModuleUi({
container: movedContainer as unknown as LoaderContainer,
context: createModuleUiContext({
config: harness.config,
descriptor: movedEntry.descriptor,
principal: after.principal,
}),
source: {
moduleId: movedEntry.descriptor.id,
url: await publishedArtifactUrl(
harness,
moduleUiSourceUrl(harness.config, movedEntry.descriptor),
),
version: movedEntry.descriptor.version,
},
});
expect(mountedText(movedContainer)).toBe("example:200:2");
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
"GET /api/v1/modules/example/records",
]);
await remounted.unmount();
} finally {
await harness.stop();
}
});
it("keeps a newly installed module visible beside an existing arrangement", async () => {
const harness = await startHarness({
extraModules: [{ id: "video", name: "Video" }],
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
try {
await (harness.config.fetch as typeof fetch)(
"https://ekklesia.example.test/api/v1/dashboard/navigation/groups/media",
{
body: JSON.stringify({ label: "Media", order: 10 }),
headers: {
"content-type": "application/json",
origin: "https://dashboard.example.test",
},
method: "PUT",
},
);
await (harness.config.fetch as typeof fetch)(
"https://ekklesia.example.test/api/v1/dashboard/navigation/placements/video",
{
body: JSON.stringify({ groupId: "media", order: 10 }),
headers: {
"content-type": "application/json",
origin: "https://dashboard.example.test",
},
method: "PUT",
},
);
const bootstrap = await bootstrapHost(harness.config);
expect(bootstrap.navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]);
// `example` was never placed, and is still reachable rather than invisible.
expect(bootstrap.navigation.ungrouped.map((entry) => entry.name)).toEqual(["Example module"]);
} finally {
await harness.stop();
}
});
it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
const harness = await startHarness();
try {
const bootstrap = await bootstrapHost(harness.config);
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
expect(bootstrap.navigation).toEqual({ groups: [], ungrouped: [] });
} finally {
await harness.stop();
}
});
it("refuses a module UI that tries to address another module's API through the host", async () => {
const harness = await startHarness({
grants: ["example.records.read"],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
const bootstrap = await bootstrapHost(harness.config);
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
if (!descriptor) {
throw new Error("the inventory returned no module");
}
const context = createModuleUiContext({
config: harness.config,
descriptor,
principal: bootstrap.principal,
});
await expect(context.api.request("../../grants?userId=someone")).rejects.toMatchObject({
code: "MODULE_API_PATH_INVALID",
});
// It never became a request at all.
expect(harness.moduleApiCalls).toEqual([]);
} finally {
await harness.stop();
}
});
});
+634
View File
@@ -0,0 +1,634 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
createAuthorizationEvaluator,
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleUiArtifactUrl } from "../src/module-ui-routes.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test";
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly id: string;
readonly version?: string;
/** Omit for a backend-only module: the manifest then declares no `entrypoints.ui` at all. */
readonly withUi?: boolean;
readonly permissions?: ReadonlyArray<{
readonly id: string;
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> {
const version = fixture.version ?? "0.1.0";
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version,
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/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,
manifestVersion: 7,
name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({
...permission,
description: `Test permission for ${fixture.id}.`,
})),
version,
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`,
);
if (fixture.withUi) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`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 {
readonly fixtures: readonly ModuleFixture[];
/** Anonymous by default, so every scenario has to opt in to being signed in. */
readonly principal?: EkklesiaPrincipal;
/** Permission ids the viewer holds an active grant for. Evaluated by Core's real evaluator, so
* baselines, the owner bypass, and deny-by-default all behave exactly as in production. */
readonly grants?: readonly string[];
readonly platformOwner?: boolean;
/** Makes the grant port fail, so the "a hint that could not be determined is never reported as
* denied" path can be exercised. */
readonly grantsUnavailable?: boolean;
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
readonly modulesDirectory: string;
readonly permissionRegistry: PermissionRegistry;
}
/** A real installation: fixtures on disk, Core's real discovery, real permission composition, the
* real publication registry, the real authorization evaluator, and the real `createApiApp`. Only
* identity resolution and the two authorization ports are doubled, because neither needs PostgreSQL
* to answer the questions these tests ask. */
async function install(options: InstallOptions): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-ui-inventory-"));
temporaryDirectories.push(modulesDirectory);
for (const fixture of options.fixtures) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const granted = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: {
hasActiveGrant: async (query) => {
if (options.grantsUnavailable) {
throw new Error("grant store unavailable");
}
return granted.has(query.permission);
},
},
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const store = {
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(),
} as unknown as AuthorizationStore;
const services: ModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
});
const permissionRegistry = composePlatformPermissionRegistry(modules);
const app = createApiApp({
authorization: { evaluator, registry: permissionRegistry, store },
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules, modulesDirectory, permissionRegistry };
}
function signedIn(): EkklesiaPrincipal {
return createAuthenticatedPrincipal(viewerId);
}
async function inventoryOf(app: Hono): Promise<{
readonly body: string;
readonly modules: ReadonlyArray<Record<string, unknown>>;
readonly response: Response;
}> {
const response = await app.request(MODULE_UI_INVENTORY_PATH);
const body = await response.clone().text();
const parsed = (await response.json()) as { modules?: ReadonlyArray<Record<string, unknown>> };
return { body, modules: parsed.modules ?? [], response };
}
describe("module UI inventory — what it lists", () => {
it("describes a publishable module with exactly the fields a host needs", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [], withUi: true }],
principal: signedIn(),
});
const { modules, response } = await inventoryOf(app);
expect(response.status).toBe(200);
expect(modules).toEqual([
{
contributions: [],
api: { baseUrl: "/api/v1/modules/example" },
display: null,
id: "example",
permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/example" },
ui: { url: "/api/v1/module-ui/example/0.1.0" },
version: "0.1.0",
},
]);
});
it("builds all host URLs with the server's own canonical helpers", async () => {
const { app, modules: discovered } = await install({
fixtures: [{ id: "example", version: "2.4.1", withUi: true }],
principal: signedIn(),
});
const module = discovered[0] as DiscoveredModule;
const { modules } = await inventoryOf(app);
// Not "looks like the right shape" — byte-identical to what the helpers produce, so a second
// hand-written template string anywhere would fail here.
expect(modules[0]?.ui).toEqual({
url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }),
});
expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) });
expect(modules[0]?.realtime).toEqual({
baseUrl: moduleRealtimeBaseUrl(module.manifest.id),
});
});
it("omits a module that declares no UI entrypoint", async () => {
const { app } = await install({
fixtures: [{ id: "calendar" }, { id: "example", withUi: true }],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.map((module) => module.id)).toEqual(["example"]);
});
it("is empty, not an error, when no installed module provides a UI", async () => {
const { app } = await install({ fixtures: [{ id: "calendar" }], principal: signedIn() });
const { modules, response } = await inventoryOf(app);
expect(response.status).toBe(200);
expect(modules).toEqual([]);
});
it("orders modules deterministically, matching the publication registry", async () => {
const { app } = await install({
fixtures: [
{ id: "signage", withUi: true },
{ id: "announcements", withUi: true },
{ id: "calendar", withUi: true },
],
principal: signedIn(),
});
const first = await inventoryOf(app);
const second = await inventoryOf(app);
// Discovery order, which is sorted; the same on every request.
expect(first.modules.map((module) => module.id)).toEqual([
"announcements",
"calendar",
"signage",
]);
expect(second.modules.map((module) => module.id)).toEqual(
first.modules.map((module) => module.id),
);
});
it("leaks no filesystem path, package root, or other manifest internal", async () => {
const {
app,
modules: discovered,
modulesDirectory,
} = await install({
fixtures: [{ id: "example", permissions: [], withUi: true }],
principal: signedIn(),
});
const artifactPath = (discovered[0] as DiscoveredModule).entrypoints.ui as string;
const { body, modules } = await inventoryOf(app);
expect(body).not.toContain(modulesDirectory);
expect(body).not.toContain(artifactPath);
expect(body).not.toContain(tmpdir());
expect(body).not.toContain("dist");
expect(body).not.toContain("@test/module-example");
expect(body).not.toContain("ekklesia.module.json");
// The exact key set, so a future field cannot be added without a deliberate decision here.
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
"api",
"contributions",
"display",
"id",
"permissions",
"realtime",
"ui",
"version",
]);
});
it("carries no navigation or presentation metadata", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
const { body } = await inventoryOf(app);
// ADR 0021 keeps Dashboard grouping an administrator's configuration, never a module
// declaration. The manifest's own `name` and `description` must not leak into it by accident.
for (const field of ["name", "description", "icon", "category", "folder", "order", "group"]) {
expect(body, field).not.toContain(`"${field}"`);
}
});
});
describe("module UI inventory — authentication", () => {
it("refuses an anonymous caller", async () => {
const { app } = await install({ fixtures: [{ id: "example", withUi: true }] });
const response = await app.request(MODULE_UI_INVENTORY_PATH);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("tells an anonymous caller nothing about what is installed", async () => {
const { app } = await install({
fixtures: [
{ id: "example", withUi: true },
{ id: "calendar", withUi: true },
],
});
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
expect(body).not.toContain("example");
expect(body).not.toContain("calendar");
});
it("admits any signed-in user without needing an explicit grant", async () => {
// `core.modules.ui.read` is baseline `authenticated`, so being signed in is enough — a shell
// that rendered nothing until an operator granted every user a permission would be unusable.
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
grants: [],
principal: signedIn(),
platformOwner: false,
});
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
});
it("is never cached and varies on the caller's identity", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
const response = await app.request(MODULE_UI_INVENTORY_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("cache-control")).toBe("no-store");
const vary = response.headers.get("vary")?.toLowerCase() ?? "";
expect(vary).toContain("origin");
expect(vary).toContain("cookie");
});
});
describe("module UI inventory — permission hints", () => {
const readPermission = { baseline: "restricted" as const, id: "example.records.read" };
const writePermission = { baseline: "restricted" as const, id: "example.records.write" };
it("returns only the permissions the server actually granted this principal", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
grants: ["example.records.read"],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules[0]?.permissions).toEqual(["example.records.read"]);
});
it("returns none of them for a signed-in user holding no grant", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
grants: [],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
// Never a default of true, and never the module's full declared surface.
expect(modules[0]?.permissions).toEqual([]);
});
it("honours a baseline that needs no grant at all", async () => {
const { app } = await install({
fixtures: [
{
id: "example",
permissions: [{ baseline: "authenticated", id: "example.records.list" }, readPermission],
withUi: true,
},
],
grants: [],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules[0]?.permissions).toEqual(["example.records.list"]);
});
it("honours the platform-owner bypass, exactly like a real request would", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
grants: [],
platformOwner: true,
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules[0]?.permissions).toEqual(["example.records.read", "example.records.write"]);
});
it("scopes hints to each module's own declared permissions", async () => {
const { app } = await install({
fixtures: [
{ id: "example", permissions: [readPermission], withUi: true },
{
id: "calendar",
permissions: [{ baseline: "restricted", id: "calendar.events.read" }],
withUi: true,
},
],
grants: ["example.records.read", "calendar.events.read"],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
const byId = new Map(modules.map((module) => [module.id, module.permissions]));
// Neither module learns anything about the other's authorization state, and neither is told
// about Core's own permissions.
expect(byId.get("example")).toEqual(["example.records.read"]);
expect(byId.get("calendar")).toEqual(["calendar.events.read"]);
expect(JSON.stringify(modules)).not.toContain("core.authorization.grants.manage");
expect(JSON.stringify(modules)).not.toContain("core.modules.ui.read");
});
it("reports an evaluation outage as unavailable rather than as 'nothing granted'", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission], withUi: true }],
grantsUnavailable: true,
principal: signedIn(),
});
const response = await app.request(MODULE_UI_INVENTORY_PATH);
// A hint that silently read "denied" during an outage would blank the UI and look like a
// permissions problem.
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
});
});
describe("module UI inventory — it is not a module management API", () => {
it("routes no method other than GET", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
const response = await app.request(MODULE_UI_INVENTORY_PATH, { method });
expect(response.status, method).toBe(404);
expect(await response.json(), method).toEqual({ code: "NOT_FOUND" });
}
});
it("still refuses to serve an artifact under the collection path", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
for (const suffix of ["/example", "/example/", "/example/0.1.0/index.js"]) {
const response = await app.request(`${MODULE_UI_INVENTORY_PATH}${suffix}`);
expect(response.status, suffix).toBe(404);
}
});
});
/**
* 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",
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -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",
);
}
@@ -0,0 +1,86 @@
import type {
DashboardConfiguration,
DashboardGroupRecord,
DashboardPlacementRecord,
DashboardStore,
SetDashboardPlacementInput,
UpsertDashboardGroupInput,
} from "@ekklesia/core/dashboard";
import { DashboardStoreError } from "@ekklesia/core/dashboard";
/**
* An in-memory `DashboardStore` for tests that exercise **routing and resolution**, not persistence.
*
* It reproduces only the two behaviours those tests depend on — a placement may not name a group
* that does not exist, and deleting a group ungroups its modules (the schema's
* `on delete set null`) — and deliberately reproduces nothing else. Everything the real store owns
* that this double would only be guessing at (identifier grammar, label trimming and bounds,
* ordering bounds, transactional behaviour, and the actual `on delete set null` constraint) is
* proven against real PostgreSQL in `core/tests/dashboard-store.integration.ts` instead, so this
* double can never quietly stand in for a persistence guarantee nobody checked.
*/
export function createInMemoryDashboardStore(
seed: Partial<DashboardConfiguration> = {},
): DashboardStore {
const groups = new Map<string, DashboardGroupRecord>(
(seed.groups ?? []).map((group) => [group.id, { ...group }] as const),
);
const placements = new Map<string, DashboardPlacementRecord>(
(seed.placements ?? []).map((placement) => [placement.moduleId, { ...placement }] as const),
);
const sorted = <Record extends { readonly sortOrder: number }>(
records: readonly Record[],
key: (record: Record) => string,
): readonly Record[] =>
Object.freeze(
[...records].sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
(key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0),
),
);
return Object.freeze({
async deleteGroup(groupId: string): Promise<void> {
if (!groups.delete(groupId)) {
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
}
for (const [moduleId, placement] of placements) {
if (placement.groupId === groupId) {
placements.set(moduleId, { ...placement, groupId: null });
}
}
},
async readConfiguration(): Promise<DashboardConfiguration> {
return Object.freeze({
groups: sorted([...groups.values()], (group) => group.id),
placements: sorted([...placements.values()], (placement) => placement.moduleId),
});
},
async setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord> {
if (input.groupId !== null && !groups.has(input.groupId)) {
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
}
const record: DashboardPlacementRecord = Object.freeze({
groupId: input.groupId,
moduleId: input.moduleId,
sortOrder: input.sortOrder,
});
placements.set(input.moduleId, record);
return record;
},
async upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord> {
const record: DashboardGroupRecord = Object.freeze({
id: input.id,
label: input.label.trim(),
sortOrder: input.sortOrder,
});
groups.set(input.id, record);
return record;
},
});
}
@@ -0,0 +1,76 @@
import type { ModuleEnablementRecord } from "@ekklesia/contracts";
import type {
DiscoveredModule,
ModuleLifecycleConfiguration,
ModuleLifecycleSnapshot,
ModuleLifecycleStore,
SetModuleEnabledInput,
} from "@ekklesia/core/modules";
import { ModuleLifecycleStoreError, resolveModuleEligibility } from "@ekklesia/core/modules";
/**
* An in-memory `ModuleLifecycleStore` for tests that exercise **routing and resolution**, not
* persistence.
*
* It reproduces only what those tests depend on — records survive a write and read back in
* module-id order, and an invalid identifier is rejected — and deliberately reproduces nothing
* else. The real table's constraints, the upsert's conflict target, and the fact that a record for
* an undeployed module is retained are proven against real PostgreSQL in
* `core/tests/module-lifecycle-store.integration.ts`, so this double can never quietly stand in for
* a persistence guarantee nobody checked.
*/
export function createInMemoryModuleLifecycleStore(
seed: readonly ModuleEnablementRecord[] = [],
): ModuleLifecycleStore {
const records = new Map<string, boolean>(
seed.map((record) => [record.moduleId, record.enabled] as const),
);
return Object.freeze({
async readConfiguration(): Promise<ModuleLifecycleConfiguration> {
return Object.freeze({
modules: Object.freeze(
[...records.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([moduleId, enabled]) => Object.freeze({ enabled, moduleId })),
),
});
},
async setEnabled(input: SetModuleEnabledInput): Promise<ModuleEnablementRecord> {
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(input.moduleId)) {
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID");
}
records.set(input.moduleId, input.enabled);
return Object.freeze({ enabled: input.enabled, moduleId: input.moduleId });
},
});
}
/**
* Builds the boot snapshot the same way `startApi` does — resolve eligibility from the discovered
* inventory and the stored configuration, then record what would have started — rather than
* hand-writing one, so a test can never assert against a snapshot the real composition would never
* produce.
*/
export function moduleLifecycleSnapshotFor(
discoveredModules: readonly DiscoveredModule[],
configuration: ModuleLifecycleConfiguration = { modules: [] },
): ModuleLifecycleSnapshot {
const resolution = resolveModuleEligibility(discoveredModules, configuration);
return Object.freeze({
discoveredModules,
eligibility: resolution.entries,
startedModuleIds: Object.freeze(resolution.eligibleModules.map((module) => module.manifest.id)),
});
}
/** The lifecycle option for a `createApiApp` caller that exercises no modules at all — the explicit
* "no modules" statement the required option asks for, never an implicit default. */
export function emptyModuleLifecycle(): {
readonly snapshot: ModuleLifecycleSnapshot;
readonly store: ModuleLifecycleStore;
} {
return {
snapshot: moduleLifecycleSnapshotFor([]),
store: createInMemoryModuleLifecycleStore(),
};
}
@@ -0,0 +1,208 @@
import {
type ModuleStorage,
type ModuleStorageBody,
ModuleStorageError,
type ModuleStorageListing,
type ModuleStorageListOptions,
type ModuleStorageObject,
type ModuleStorageObjectMetadata,
type ModuleStoragePutOptions,
type ModuleStorageReadUrl,
} from "@ekklesia/contracts";
import type { DiscoveredModule, ModuleStorageLookup } from "@ekklesia/core/modules";
import { createModuleStorageLookup, type StorageProvider } from "@ekklesia/core/storage";
/** Builds and drains Web streams without depending on DOM-only helper types, which this package's
* TypeScript configuration deliberately does not load. */
function streamOf(bytes: Uint8Array): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value);
total += value.byteLength;
}
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
return merged;
}
interface StoredObject {
readonly bytes: Uint8Array;
readonly contentType?: string;
readonly lastModified: Date;
}
/**
* An in-memory `StorageProvider` for tests that exercise **wiring, scoping, and routing** rather
* than the S3 protocol.
*
* It implements the same interface the real S3 provider does, so everything above the provider seam
* — binding resolution, the scoped facade, lifecycle eligibility, route wiring — is exercised
* unchanged. What it deliberately does *not* reproduce is the protocol itself: real SeaweedFS
* semantics (non-idempotent `CreateBucket`, `NoSuchKey` vs `NoSuchBucket`, ETag quoting, streaming
* bodies) are proven against a live server in `core/tests/module-storage.integration.ts`, so this
* double can never quietly stand in for a provider guarantee nobody checked.
*
* It keys everything by **physical** bucket, exactly as the real provider does, which is what lets a
* test assert that two modules declaring the same logical name really did land in different
* namespaces.
*/
export function createInMemoryStorageProvider(): StorageProvider & {
readonly buckets: ReadonlyMap<string, Map<string, StoredObject>>;
ensureBucketCalls: number;
} {
const buckets = new Map<string, Map<string, StoredObject>>();
const state = { ensureBucketCalls: 0 };
const objectsIn = (physicalBucket: string): Map<string, StoredObject> => {
const bucket = buckets.get(physicalBucket);
if (!bucket) {
throw new ModuleStorageError("STORAGE_BUCKET_UNAVAILABLE");
}
return bucket;
};
const metadataFor = (key: string, stored: StoredObject): ModuleStorageObjectMetadata =>
Object.freeze({
contentLength: stored.bytes.byteLength,
key,
lastModified: stored.lastModified,
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
});
const provider: StorageProvider = {
async close(): Promise<void> {
// Nothing to release.
},
async createReadUrl(
physicalBucket: string,
key: string,
urlOptions: { readonly expiresInSeconds: number; readonly downloadFilename?: string },
): Promise<ModuleStorageReadUrl> {
// Keyed by physical bucket like everything else here, so a route test can prove the URL a
// handler received belongs to that module's namespace and not another's.
return Object.freeze({
expiresAt: new Date(Date.now() + urlOptions.expiresInSeconds * 1000),
url: `https://delivery.test/${physicalBucket}/${encodeURIComponent(key)}?token=fake`,
});
},
async delete(physicalBucket: string, key: string): Promise<void> {
objectsIn(physicalBucket).delete(key);
},
async ensureBucket(physicalBucket: string): Promise<void> {
state.ensureBucketCalls += 1;
if (!buckets.has(physicalBucket)) {
buckets.set(physicalBucket, new Map());
}
},
async get(physicalBucket: string, key: string): Promise<ModuleStorageObject> {
const stored = objectsIn(physicalBucket).get(key);
if (!stored) {
throw new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
}
return Object.freeze({
...metadataFor(key, stored),
body: streamOf(stored.bytes),
});
},
async head(physicalBucket: string, key: string): Promise<ModuleStorageObjectMetadata> {
const stored = objectsIn(physicalBucket).get(key);
if (!stored) {
throw new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
}
return metadataFor(key, stored);
},
async list(
physicalBucket: string,
options: ModuleStorageListOptions,
): Promise<ModuleStorageListing> {
const entries = [...objectsIn(physicalBucket).entries()]
.filter(([key]) => options.prefix === undefined || key.startsWith(options.prefix))
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
return Object.freeze({
objects: Object.freeze(entries.map(([key, stored]) => metadataFor(key, stored))),
});
},
async probe(): Promise<void> {
// Always reachable.
},
async put(
physicalBucket: string,
key: string,
body: ModuleStorageBody,
options: ModuleStoragePutOptions,
): Promise<ModuleStorageObjectMetadata> {
const bytes =
typeof body === "string"
? new TextEncoder().encode(body)
: body instanceof Uint8Array
? body
: await collect(body as ReadableStream<Uint8Array>);
const stored: StoredObject = {
bytes,
lastModified: new Date(0),
...(options.contentType === undefined ? {} : { contentType: options.contentType }),
};
objectsIn(physicalBucket).set(key, stored);
return metadataFor(key, stored);
},
};
return Object.assign(provider, {
buckets: buckets as ReadonlyMap<string, Map<string, StoredObject>>,
get ensureBucketCalls(): number {
return state.ensureBucketCalls;
},
});
}
export interface ModuleStorageOption {
readonly snapshots: ModuleStorageLookup;
readonly providerConfigured: boolean;
}
/**
* Builds the `createApiApp` storage option the same way `startApi` does — resolve every module's
* declared buckets against one provider and one prefix — rather than hand-writing a map, so a test
* can never assert against a shape the real composition would not produce.
*/
export function moduleStorageOptionFor(
discoveredModules: readonly DiscoveredModule[],
provider?: StorageProvider,
): ModuleStorageOption {
return {
providerConfigured: provider !== undefined,
snapshots: createModuleStorageLookup(
provider,
"ekklesia",
discoveredModules.map((module) => ({
logicalBucketIds: module.manifest.storage.buckets.map((bucket) => bucket.id),
moduleId: module.manifest.id,
})),
),
};
}
/** The storage option for a `createApiApp` caller that exercises no storage at all — the explicit
* "this installation has no object storage" statement the required option asks for. */
export function emptyModuleStorage(): ModuleStorageOption {
return { providerConfigured: false, snapshots: new Map<string, ModuleStorage>() };
}
+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,
};
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ekklesia/typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: [
"./src/identity/schema.ts",
"./src/authorization/schema.ts",
"./src/dashboard/schema.ts",
"./src/modules/lifecycle-schema.ts",
"./src/modules/configuration-schema.ts",
"./src/devices/schema.ts",
],
out: "./drizzle",
migrations: {
prefix: "index",
},
breakpoints: true,
});
@@ -0,0 +1,53 @@
CREATE TABLE "ekklesia_core_account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "ekklesia_core_session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_account" ADD CONSTRAINT "ekklesia_core_account_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_session" ADD CONSTRAINT "ekklesia_core_session_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ekklesia_core_account_user_id_idx" ON "ekklesia_core_account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "ekklesia_core_session_user_id_idx" ON "ekklesia_core_session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "ekklesia_core_verification_identifier_idx" ON "ekklesia_core_verification" USING btree ("identifier");
@@ -0,0 +1,51 @@
CREATE TABLE "ekklesia_core_authorization_audit" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"action" text NOT NULL,
"actor_type" text NOT NULL,
"actor_id" text,
"subject_user_id" text NOT NULL,
"permission_id" text,
"scope" text,
"grant_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_authorization_audit_action_check" CHECK ("ekklesia_core_authorization_audit"."action" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')),
CONSTRAINT "ekklesia_core_authorization_audit_actor_type_check" CHECK ("ekklesia_core_authorization_audit"."actor_type" in ('user', 'system')),
CONSTRAINT "ekklesia_core_authorization_audit_scope_check" CHECK ("ekklesia_core_authorization_audit"."scope" is null or length("ekklesia_core_authorization_audit"."scope") > 0)
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_grant" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"permission_id" text NOT NULL,
"scope" text,
"source_kind" text NOT NULL,
"source_ref" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_by_actor_type" text NOT NULL,
"created_by_actor_id" text,
"expires_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"revoked_by_actor_type" text,
"revoked_by_actor_id" text,
CONSTRAINT "ekklesia_core_grant_source_kind_check" CHECK ("ekklesia_core_grant"."source_kind" in ('explicit', 'relationship')),
CONSTRAINT "ekklesia_core_grant_created_actor_type_check" CHECK ("ekklesia_core_grant"."created_by_actor_type" in ('user', 'system')),
CONSTRAINT "ekklesia_core_grant_revoked_actor_type_check" CHECK ("ekklesia_core_grant"."revoked_by_actor_type" is null or "ekklesia_core_grant"."revoked_by_actor_type" in ('user', 'system'))
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_platform_owner" (
"user_id" text PRIMARY KEY NOT NULL,
"granted_at" timestamp with time zone DEFAULT now() NOT NULL,
"granted_by_actor_type" text NOT NULL,
"granted_by_actor_id" text,
"revoked_at" timestamp with time zone,
"revoked_by_actor_type" text,
"revoked_by_actor_id" text,
CONSTRAINT "ekklesia_core_platform_owner_granted_actor_type_check" CHECK ("ekklesia_core_platform_owner"."granted_by_actor_type" in ('user', 'system')),
CONSTRAINT "ekklesia_core_platform_owner_revoked_actor_type_check" CHECK ("ekklesia_core_platform_owner"."revoked_by_actor_type" is null or "ekklesia_core_platform_owner"."revoked_by_actor_type" in ('user', 'system'))
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_authorization_audit" ADD CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_grant" ADD CONSTRAINT "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_platform_owner" ADD CONSTRAINT "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ekklesia_core_authorization_audit_subject_idx" ON "ekklesia_core_authorization_audit" USING btree ("subject_user_id");--> statement-breakpoint
CREATE INDEX "ekklesia_core_grant_user_permission_idx" ON "ekklesia_core_grant" USING btree ("user_id","permission_id");
@@ -0,0 +1,13 @@
CREATE TABLE "ekklesia_core_platform_owner_bootstrap" (
"id" text PRIMARY KEY NOT NULL,
"consumed_at" timestamp with time zone DEFAULT now() NOT NULL,
"consumed_for_user_id" text,
CONSTRAINT "ekklesia_core_platform_owner_bootstrap_singleton_check" CHECK ("ekklesia_core_platform_owner_bootstrap"."id" = 'singleton')
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_authorization_audit" DROP CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk";
--> statement-breakpoint
INSERT INTO "ekklesia_core_platform_owner_bootstrap" ("id")
SELECT 'singleton'
WHERE EXISTS (SELECT 1 FROM "ekklesia_core_platform_owner")
ON CONFLICT ("id") DO NOTHING;
@@ -0,0 +1,22 @@
CREATE TABLE "ekklesia_core_dashboard_group" (
"id" text PRIMARY KEY NOT NULL,
"label" text NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_dashboard_group_id_check" CHECK (length("ekklesia_core_dashboard_group"."id") > 0),
CONSTRAINT "ekklesia_core_dashboard_group_label_check" CHECK (length(btrim("ekklesia_core_dashboard_group"."label")) > 0)
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_dashboard_placement" (
"module_id" text PRIMARY KEY NOT NULL,
"group_id" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_dashboard_placement_module_check" CHECK (length("ekklesia_core_dashboard_placement"."module_id") > 0)
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_dashboard_placement" ADD CONSTRAINT "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."ekklesia_core_dashboard_group"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ekklesia_core_dashboard_group_order_idx" ON "ekklesia_core_dashboard_group" USING btree ("sort_order","id");--> statement-breakpoint
CREATE INDEX "ekklesia_core_dashboard_placement_group_idx" ON "ekklesia_core_dashboard_placement" USING btree ("group_id","sort_order");
@@ -0,0 +1,7 @@
CREATE TABLE "ekklesia_core_module_state" (
"module_id" text PRIMARY KEY NOT NULL,
"enabled" boolean NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_module_state_module_check" CHECK (length("ekklesia_core_module_state"."module_id") > 0)
);
@@ -0,0 +1,13 @@
CREATE TABLE "ekklesia_core_module_configuration" (
"module_id" text NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_module_configuration_module_id_key_pk" PRIMARY KEY("module_id","key"),
CONSTRAINT "ekklesia_core_module_configuration_module_check" CHECK (length("ekklesia_core_module_configuration"."module_id") > 0),
CONSTRAINT "ekklesia_core_module_configuration_key_check" CHECK (length("ekklesia_core_module_configuration"."key") > 0),
CONSTRAINT "ekklesia_core_module_configuration_value_check" CHECK (length("ekklesia_core_module_configuration"."value") <= 2048)
);
--> statement-breakpoint
CREATE INDEX "ekklesia_core_module_configuration_module_idx" ON "ekklesia_core_module_configuration" USING btree ("module_id");
+53
View File
@@ -0,0 +1,53 @@
CREATE TABLE "ekklesia_core_device" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"credential_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_by_user_id" text,
"last_seen_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"revoked_by_user_id" text,
CONSTRAINT "ekklesia_core_device_name_check" CHECK (length("ekklesia_core_device"."name") between 1 and 128),
CONSTRAINT "ekklesia_core_device_credential_hash_check" CHECK (length("ekklesia_core_device"."credential_hash") = 64)
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_device_pairing_request" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" text NOT NULL,
"claim_secret_hash" text NOT NULL,
"module_id" text NOT NULL,
"permission_id" text NOT NULL,
"state" text DEFAULT 'pending' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"device_name" text,
"approved_at" timestamp with time zone,
"approved_by_user_id" text,
"consumed_at" timestamp with time zone,
"device_id" uuid,
CONSTRAINT "ekklesia_core_device_pairing_code_check" CHECK ("ekklesia_core_device_pairing_request"."code" ~ '^[0-9]{6}$'),
CONSTRAINT "ekklesia_core_device_pairing_claim_hash_check" CHECK (length("ekklesia_core_device_pairing_request"."claim_secret_hash") = 64),
CONSTRAINT "ekklesia_core_device_pairing_state_check" CHECK ("ekklesia_core_device_pairing_request"."state" in ('pending', 'approved', 'consumed'))
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_device_permission" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"device_id" uuid NOT NULL,
"permission_id" text NOT NULL,
"granted_at" timestamp with time zone DEFAULT now() NOT NULL,
"granted_by_user_id" text,
"revoked_at" timestamp with time zone,
CONSTRAINT "ekklesia_core_device_permission_id_check" CHECK (length("ekklesia_core_device_permission"."permission_id") between 1 and 128)
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_device" ADD CONSTRAINT "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device" ADD CONSTRAINT "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("revoked_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("approved_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."ekklesia_core_device"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device_permission" ADD CONSTRAINT "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."ekklesia_core_device"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device_permission" ADD CONSTRAINT "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("granted_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "ekklesia_core_device_credential_hash_idx" ON "ekklesia_core_device" USING btree ("credential_hash");--> statement-breakpoint
CREATE INDEX "ekklesia_core_device_revoked_idx" ON "ekklesia_core_device" USING btree ("revoked_at");--> statement-breakpoint
CREATE UNIQUE INDEX "ekklesia_core_device_pairing_code_idx" ON "ekklesia_core_device_pairing_request" USING btree ("code") WHERE "ekklesia_core_device_pairing_request"."state" <> 'consumed';--> statement-breakpoint
CREATE INDEX "ekklesia_core_device_pairing_claim_idx" ON "ekklesia_core_device_pairing_request" USING btree ("claim_secret_hash");--> statement-breakpoint
CREATE INDEX "ekklesia_core_device_permission_lookup_idx" ON "ekklesia_core_device_permission" USING btree ("device_id","permission_id");
@@ -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
);
+362
View File
@@ -0,0 +1,362 @@
{
"id": "42cf39d2-7833-4bf3-9043-4e5303da8a75",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+683
View File
@@ -0,0 +1,683 @@
{
"id": "dfd53f55-6e2a-45e1-9962-469ea6eca6e7",
"prevId": "42cf39d2-7833-4bf3-9043-4e5303da8a75",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_authorization_audit",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["subject_user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+710
View File
@@ -0,0 +1,710 @@
{
"id": "fac36f3d-7551-42df-a967-aad65c2fe7f9",
"prevId": "dfd53f55-6e2a-45e1-9962-469ea6eca6e7",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner_bootstrap": {
"name": "ekklesia_core_platform_owner_bootstrap",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"consumed_for_user_id": {
"name": "consumed_for_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+870
View File
@@ -0,0 +1,870 @@
{
"id": "fd5d1b32-781c-43ed-9d53-98bd5d395e6a",
"prevId": "fac36f3d-7551-42df-a967-aad65c2fe7f9",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner_bootstrap": {
"name": "ekklesia_core_platform_owner_bootstrap",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"consumed_for_user_id": {
"name": "consumed_for_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_group": {
"name": "ekklesia_core_dashboard_group",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_group_order_idx": {
"name": "ekklesia_core_dashboard_group_order_idx",
"columns": [
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_group_id_check": {
"name": "ekklesia_core_dashboard_group_id_check",
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
},
"ekklesia_core_dashboard_group_label_check": {
"name": "ekklesia_core_dashboard_group_label_check",
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_placement": {
"name": "ekklesia_core_dashboard_placement",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"group_id": {
"name": "group_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_placement_group_idx": {
"name": "ekklesia_core_dashboard_placement_group_idx",
"columns": [
{
"expression": "group_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
"tableFrom": "ekklesia_core_dashboard_placement",
"tableTo": "ekklesia_core_dashboard_group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_placement_module_check": {
"name": "ekklesia_core_dashboard_placement_module_check",
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+914
View File
@@ -0,0 +1,914 @@
{
"id": "b153e7a4-3100-4610-b296-a1a0b3cbfff6",
"prevId": "fd5d1b32-781c-43ed-9d53-98bd5d395e6a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner_bootstrap": {
"name": "ekklesia_core_platform_owner_bootstrap",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"consumed_for_user_id": {
"name": "consumed_for_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_group": {
"name": "ekklesia_core_dashboard_group",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_group_order_idx": {
"name": "ekklesia_core_dashboard_group_order_idx",
"columns": [
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_group_id_check": {
"name": "ekklesia_core_dashboard_group_id_check",
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
},
"ekklesia_core_dashboard_group_label_check": {
"name": "ekklesia_core_dashboard_group_label_check",
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_placement": {
"name": "ekklesia_core_dashboard_placement",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"group_id": {
"name": "group_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_placement_group_idx": {
"name": "ekklesia_core_dashboard_placement_group_idx",
"columns": [
{
"expression": "group_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
"tableFrom": "ekklesia_core_dashboard_placement",
"tableTo": "ekklesia_core_dashboard_group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_placement_module_check": {
"name": "ekklesia_core_dashboard_placement_module_check",
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_module_state": {
"name": "ekklesia_core_module_state",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_module_state_module_check": {
"name": "ekklesia_core_module_state_module_check",
"value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+993
View File
@@ -0,0 +1,993 @@
{
"id": "d41e490c-dbe1-4d73-9e11-6e79baa91908",
"prevId": "b153e7a4-3100-4610-b296-a1a0b3cbfff6",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner_bootstrap": {
"name": "ekklesia_core_platform_owner_bootstrap",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"consumed_for_user_id": {
"name": "consumed_for_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_group": {
"name": "ekklesia_core_dashboard_group",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_group_order_idx": {
"name": "ekklesia_core_dashboard_group_order_idx",
"columns": [
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_group_id_check": {
"name": "ekklesia_core_dashboard_group_id_check",
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
},
"ekklesia_core_dashboard_group_label_check": {
"name": "ekklesia_core_dashboard_group_label_check",
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_placement": {
"name": "ekklesia_core_dashboard_placement",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"group_id": {
"name": "group_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_placement_group_idx": {
"name": "ekklesia_core_dashboard_placement_group_idx",
"columns": [
{
"expression": "group_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
"tableFrom": "ekklesia_core_dashboard_placement",
"tableTo": "ekklesia_core_dashboard_group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_placement_module_check": {
"name": "ekklesia_core_dashboard_placement_module_check",
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_module_state": {
"name": "ekklesia_core_module_state",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_module_state_module_check": {
"name": "ekklesia_core_module_state_module_check",
"value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_module_configuration": {
"name": "ekklesia_core_module_configuration",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_module_configuration_module_idx": {
"name": "ekklesia_core_module_configuration_module_idx",
"columns": [
{
"expression": "module_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"ekklesia_core_module_configuration_module_id_key_pk": {
"name": "ekklesia_core_module_configuration_module_id_key_pk",
"columns": ["module_id", "key"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_module_configuration_module_check": {
"name": "ekklesia_core_module_configuration_module_check",
"value": "length(\"ekklesia_core_module_configuration\".\"module_id\") > 0"
},
"ekklesia_core_module_configuration_key_check": {
"name": "ekklesia_core_module_configuration_key_check",
"value": "length(\"ekklesia_core_module_configuration\".\"key\") > 0"
},
"ekklesia_core_module_configuration_value_check": {
"name": "ekklesia_core_module_configuration_value_check",
"value": "length(\"ekklesia_core_module_configuration\".\"value\") <= 2048"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
{
"version": 1,
"moduleId": "core",
"entries": [
{
"tag": "0000_identity_core_tables",
"destructive": {
"mode": "none"
}
},
{
"tag": "0001_authorization_core_tables",
"destructive": {
"mode": "none"
}
},
{
"tag": "0002_authorization_integrity",
"destructive": {
"mode": "none"
}
},
{
"tag": "0003_dashboard_navigation_tables",
"destructive": {
"mode": "none"
}
},
{
"tag": "0004_module_lifecycle_state",
"destructive": {
"mode": "none"
}
},
{
"tag": "0005_module_configuration",
"destructive": {
"mode": "none"
}
},
{
"tag": "0006_device_identity",
"destructive": {
"mode": "none"
}
},
{
"tag": "0007_verified_bootstrap_owners",
"destructive": {
"mode": "none"
}
}
]
}
+62
View File
@@ -0,0 +1,62 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1784668552522,
"tag": "0000_identity_core_tables",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1784819635300,
"tag": "0001_authorization_core_tables",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785104670124,
"tag": "0002_authorization_integrity",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1787408382271,
"tag": "0003_dashboard_navigation_tables",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1787423121678,
"tag": "0004_module_lifecycle_state",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1787451496425,
"tag": "0005_module_configuration",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1787543773057,
"tag": "0006_device_identity",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1787543773058,
"tag": "0007_verified_bootstrap_owners",
"breakpoints": true
}
]
}
+19
View File
@@ -0,0 +1,19 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
/**
* Drives the pinned Better Auth CLI's canonical schema generator for
* `pnpm identity:schema:generate` and `pnpm identity:schema:validate`.
*
* This file is a development-time aid only: it is never imported by application, factory, or
* runtime code, never opens a database connection, and intentionally omits our custom table
* names so the generator always produces Better Auth's official default (unrenamed) schema for
* comparison against core/src/identity/schema.ts. Do not add plugins or providers here.
*/
export const auth = betterAuth({
database: drizzleAdapter({} as never, {
provider: "pg",
}),
secret: "0".repeat(32),
baseURL: "https://identity-schema-generator.invalid",
});
+56 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/core", "name": "@ekklesia/core",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"files": [ "files": [
@@ -14,20 +14,74 @@
"./modules": { "./modules": {
"types": "./dist/modules/index.d.ts", "types": "./dist/modules/index.d.ts",
"import": "./dist/modules/index.js" "import": "./dist/modules/index.js"
},
"./storage": {
"types": "./dist/storage/index.d.ts",
"import": "./dist/storage/index.js"
},
"./dashboard": {
"types": "./dist/dashboard/index.d.ts",
"import": "./dist/dashboard/index.js"
},
"./migrations": {
"types": "./dist/migrations/index.d.ts",
"import": "./dist/migrations/index.js"
},
"./diagnostics": {
"types": "./dist/diagnostics/index.d.ts",
"import": "./dist/diagnostics/index.js"
},
"./identity": {
"types": "./dist/identity/index.d.ts",
"import": "./dist/identity/index.js"
},
"./authorization": {
"types": "./dist/authorization/index.d.ts",
"import": "./dist/authorization/index.js"
},
"./database": {
"types": "./dist/database/index.d.ts",
"import": "./dist/database/index.js"
},
"./devices": {
"types": "./dist/devices/index.d.ts",
"import": "./dist/devices/index.js"
},
"./mail": {
"types": "./dist/mail/index.d.ts",
"import": "./dist/mail/index.js"
},
"./host": {
"types": "./dist/host/index.d.ts",
"import": "./dist/host/index.js"
} }
}, },
"scripts": { "scripts": {
"build": "tsc --project tsconfig.build.json", "build": "tsc --project tsconfig.build.json",
"identity:migration:generate": "drizzle-kit generate --config=drizzle.config.ts",
"identity:schema:generate": "auth generate --config=identity-schema-generator.config.ts --output=.identity-schema-reference.ts -y",
"lint": "biome lint .", "lint": "biome lint .",
"test": "vitest run", "test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json" "typecheck": "tsc --noEmit --project tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@ekklesia/contracts": "workspace:*" "@aws-sdk/client-s3": "3.1116.0",
"@aws-sdk/s3-request-presigner": "3.1116.0",
"@ekklesia/contracts": "workspace:*",
"better-auth": "1.6.23",
"drizzle-orm": "0.45.2",
"nodemailer": "9.0.5",
"pg": "8.22.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/nodemailer": "8.0.1",
"@types/pg": "8.20.0",
"@types/smtp-server": "3.5.13",
"auth": "1.6.23",
"drizzle-kit": "0.31.10",
"smtp-server": "3.19.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitest": "4.1.10" "vitest": "4.1.10"
}, },
+137
View File
@@ -0,0 +1,137 @@
import type { EkklesiaUserId } from "@ekklesia/contracts";
import { eq, sql } from "drizzle-orm";
import { ekklesiaCoreUser } from "../identity/schema.js";
import type { AuthorizationDrizzleDatabase } from "./database.js";
import {
ekklesiaCoreAuthorizationAudit,
ekklesiaCorePlatformOwner,
ekklesiaCorePlatformOwnerBootstrap,
PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
} from "./schema.js";
export type PlatformOwnerBootstrapErrorCode =
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
| "PLATFORM_OWNER_BOOTSTRAP_FAILED"
| "PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND";
const bootstrapErrorMessages: Readonly<Record<PlatformOwnerBootstrapErrorCode, string>> =
Object.freeze({
PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED:
"Platform-owner bootstrap has already been used and cannot be used again.",
PLATFORM_OWNER_BOOTSTRAP_FAILED: "Platform-owner bootstrap could not be completed.",
PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND: "The target user does not exist.",
});
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
export class PlatformOwnerBootstrapError extends Error {
readonly code: PlatformOwnerBootstrapErrorCode;
constructor(code: PlatformOwnerBootstrapErrorCode) {
super(bootstrapErrorMessages[code]);
this.name = "PlatformOwnerBootstrapError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function isPlatformOwnerBootstrapError(error: unknown): error is PlatformOwnerBootstrapError {
return error instanceof PlatformOwnerBootstrapError;
}
/** A fixed, sanitized, non-user actor identity for the audit trail — distinguishable from both an
* ordinary "system" action with no specific actor and from any real `EkklesiaUserId`, so an
* auditor can tell this owner grant came specifically from the bootstrap command. Never a real
* user id and never derived from caller input. */
const BOOTSTRAP_ACTOR_TYPE = "system";
const BOOTSTRAP_ACTOR_ID = "authorization-bootstrap-cli";
// A dedicated advisory-lock key pair, distinct from `core/src/migrations/postgres-database.ts`'s
// migration lock, so bootstrap serialization can never collide with or be blocked by unrelated
// migration locking. `pg_advisory_xact_lock` is transaction-scoped: it is released automatically
// on COMMIT or ROLLBACK, so no manual unlock is needed even if the transaction fails partway.
const bootstrapLockNamespace = 1_500_000_001;
const bootstrapLockKey = 1_500_000_002;
export interface BootstrapPlatformOwnerInput {
readonly userId: EkklesiaUserId;
}
/**
* Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a
* composition of "check, then call the existing grant function": the single-use claim, the target
* user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL
* transaction, serialized by a transaction-scoped advisory lock acquired as the very first
* statement — so two concurrent callers can never both observe "not yet consumed" and both insert.
* The second caller blocks on the lock until the first's transaction commits or rolls back, then
* re-evaluates against the now-current state.
*
* **Single-use authority lives in `ekklesia_core_platform_owner_bootstrap`, never in the continued
* existence of a platform-owner row.** The owner table's `user_id` cascades on user deletion, so
* treating "an owner row exists" as the consumed marker meant deleting the bootstrapped owner's
* user row silently reopened the one operation that establishes a platform's first administrator —
* along with the audit evidence that it had ever run. The dedicated singleton has no user foreign
* key, is never deleted by Core, and therefore survives owner revocation and user deletion alike.
*
* Consumption is deliberately tied to *success*: the sentinel is written inside the same
* transaction as the owner row and its audit entry, so a missing target user, a constraint
* violation, or any other failure rolls the sentinel back with everything else and leaves the one
* legitimate first use still available. There is no reset command, deletion path, HTTP endpoint,
* environment override, or force flag — by design, "consumed" is permanent.
*/
export async function bootstrapPlatformOwner(
db: AuthorizationDrizzleDatabase,
input: BootstrapPlatformOwnerInput,
): Promise<void> {
try {
await db.transaction(async (tx) => {
await tx.execute(
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
);
const consumedRows = await tx
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
.from(ekklesiaCorePlatformOwnerBootstrap)
.limit(1);
if (consumedRows.length > 0) {
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED");
}
const targetUserRows = await tx
.select({ id: ekklesiaCoreUser.id })
.from(ekklesiaCoreUser)
.where(eq(ekklesiaCoreUser.id, input.userId))
.limit(1);
if (targetUserRows.length === 0) {
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND");
}
// The sentinel is claimed inside the same transaction as everything below it. The advisory
// lock already serializes concurrent callers; the singleton primary key is the independent,
// structural backstop that makes a second row impossible even if a future caller reached
// this insert without the lock.
await tx.insert(ekklesiaCorePlatformOwnerBootstrap).values({
consumedForUserId: input.userId,
id: PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
});
await tx.insert(ekklesiaCorePlatformOwner).values({
grantedByActorId: BOOTSTRAP_ACTOR_ID,
grantedByActorType: BOOTSTRAP_ACTOR_TYPE,
userId: input.userId,
});
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
action: "platform_owner.grant",
actorId: BOOTSTRAP_ACTOR_ID,
actorType: BOOTSTRAP_ACTOR_TYPE,
subjectUserId: input.userId,
});
});
} catch (error) {
if (isPlatformOwnerBootstrapError(error)) {
throw error;
}
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_FAILED");
}
}
+131
View File
@@ -0,0 +1,131 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import { Pool, type PoolConfig } from "pg";
import {
type DatabaseTransportPolicy,
databaseTlsConnectionOptions,
} from "../database/transport.js";
import { authorizationSchema } from "./schema.js";
export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>;
export type AuthorizationDatabaseErrorCode = "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID";
const maximumPostgresTimeoutMs = 2_147_483_647;
/** Never carries the offending connection string, host, or underlying driver error. */
export class AuthorizationDatabaseError extends Error {
readonly code: AuthorizationDatabaseErrorCode;
constructor(code: AuthorizationDatabaseErrorCode) {
super("Authorization database configuration is invalid.");
this.name = "AuthorizationDatabaseError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface AuthorizationDatabaseOptions {
readonly connectionTimeoutMs?: number;
/** Bounds both `query_timeout` and `statement_timeout`, mirroring
* `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a
* connection, not a hung query already running on one. */
readonly operationTimeoutMs?: number;
/** The validated transport policy (`core/src/database/transport.js`). Passed explicitly by
* every production executable; omitted by single-domain unit/integration factories that use
* the disposable loopback database, which keeps the pre-existing plaintext behavior. */
readonly transport?: DatabaseTransportPolicy;
}
export interface AuthorizationDatabaseHandle {
readonly close: () => Promise<void>;
readonly db: AuthorizationDrizzleDatabase;
}
function validateConnectionString(connectionString: string): string {
if (connectionString.trim().length === 0) {
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
}
let protocol: string;
try {
protocol = new URL(connectionString).protocol;
} catch {
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
}
if (protocol !== "postgres:" && protocol !== "postgresql:") {
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
}
return connectionString;
}
/** Mirrors `core/src/identity/database.ts`'s timeout validation exactly: an explicit timeout must
* be a positive safe integer no larger than PostgreSQL's own maximum, or it is rejected as invalid
* configuration rather than silently passed through to `pg`. */
function validateTimeout(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
if (
typeof value !== "number" ||
!Number.isSafeInteger(value) ||
value <= 0 ||
value > maximumPostgresTimeoutMs
) {
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
}
return value;
}
/**
* Explicitly constructs the Core-owned Drizzle database instance backing the authorization store.
* Building the `pg.Pool` performs no network I/O until a query is issued, and this function is
* only ever invoked by explicit caller code — never during module import.
*/
export function createAuthorizationDrizzleDatabase(
connectionString: string,
options: AuthorizationDatabaseOptions = {},
): AuthorizationDatabaseHandle {
const validatedConnectionString = validateConnectionString(connectionString);
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
const ssl =
options.transport === undefined
? undefined
: databaseTlsConnectionOptions(validatedConnectionString, options.transport);
const poolConfig: PoolConfig = {
connectionString: validatedConnectionString,
...(ssl === undefined ? {} : { ssl }),
...(validatedConnectionTimeoutMs === undefined
? {}
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
...(validatedOperationTimeoutMs === undefined
? {}
: {
query_timeout: validatedOperationTimeoutMs,
statement_timeout: validatedOperationTimeoutMs,
}),
};
const pool = new Pool(poolConfig);
// Same rationale as core/src/identity/database.ts: pg's own documentation requires an error
// listener on the pool, or a server-initiated termination of an idle client becomes an
// unhandled Node.js error and can crash the process.
pool.on("error", () => undefined);
const db = drizzle(pool, { schema: authorizationSchema });
return Object.freeze({
close: () => pool.end(),
db,
});
}
/**
* A typed Drizzle view over an externally owned pool — for example one shared with the identity
* schema via `createValidatedPostgresPool` (`core/src/database/pool.ts`). Unlike
* `createAuthorizationDrizzleDatabase`, this function never constructs, registers an error
* listener on, or closes a pool itself: the caller retains that responsibility exactly once,
* regardless of how many typed views it creates over the same pool.
*/
export function createAuthorizationDrizzleView(pool: Pool): AuthorizationDrizzleDatabase {
return drizzle(pool, { schema: authorizationSchema });
}
+177
View File
@@ -0,0 +1,177 @@
import {
AUTHORIZATION_ALLOWED,
AUTHORIZATION_DENIED,
type AuthorizationDecision,
type AuthorizationScope,
type EkklesiaPrincipal,
isAuthenticatedPrincipal,
isDevicePrincipal,
permissionBaselineSchema,
safeParseAuthorizationScope,
safeParsePermissionId,
} from "@ekklesia/contracts";
import type {
AuthorizationDevicePort,
AuthorizationGrantPort,
AuthorizationPlatformOwnerPort,
} from "./ports.js";
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
/** Never carries the underlying port error, database error, cause, stack, or any other internal
* detail — defense in depth on top of whatever sanitization the injected ports themselves already
* perform (`core/src/authorization/store.ts` already sanitizes), exactly like
* `core/src/identity/runtime.ts`'s `resolvePrincipal` re-sanitizes on top of Better Auth's own
* error handling rather than trusting it exclusively. */
export class AuthorizationEvaluationError extends Error {
readonly code: AuthorizationEvaluationErrorCode;
constructor() {
super("The authorization decision could not be evaluated.");
this.name = "AuthorizationEvaluationError";
this.code = "AUTHORIZATION_EVALUATION_FAILED";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface AuthorizeRequest {
readonly principal: EkklesiaPrincipal;
/** Validated internally via the branded `PermissionId` schema *before any baseline can return an
* allowed decision* — a malformed or unrecognizable value denies rather than throwing, for
* `public` and `authenticated` baselines exactly as much as `restricted`. This is shape
* validation only: the evaluator has no registry and cannot tell a well-formed-but-unregistered
* permission from one that is actually registered. Only a trusted caller consulting a
* `PermissionRegistry` (`core/src/authorization/permissions.ts`) before calling `authorize` can
* make that distinction — see that module and `apps/api/src/authorization-middleware.ts`. */
readonly permission: unknown;
/** Validated internally against the known `public`/`authenticated`/`restricted` set; anything
* else denies rather than throwing, for the same fail-closed reason. */
readonly baseline: unknown;
/** Optional; validated internally when present. Absent means an unscoped check. */
readonly scope?: unknown;
}
export interface AuthorizationEvaluator {
authorize(request: AuthorizeRequest): Promise<AuthorizationDecision>;
}
export interface CreateAuthorizationEvaluatorOptions {
readonly grants: AuthorizationGrantPort;
readonly platformOwners: AuthorizationPlatformOwnerPort;
/** Device authority. Optional so every existing caller composes unchanged; when it is absent a
* device principal simply has no permissions and is denied, which is the correct answer for a
* deployment that has no device support wired at all. */
readonly devices?: AuthorizationDevicePort;
/** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the
* real wall clock; production composition must leave this unset. */
readonly now?: () => Date;
}
/**
* Core's Hono-independent, deny-by-default authorization evaluator. It is the only place
* Ekklesia decides whether a principal may exercise a permission, and it never trusts anything
* the caller supplies beyond `principal`/`permission`/`baseline`/`scope` — there is no field for a
* caller to assert "I am the owner" or "I already have this grant." Owner and grant state are
* always re-derived from the injected ports for every call.
*
* A genuine infrastructure failure from either port (for example a database outage) is
* deliberately NOT swallowed into a `denied` decision here: it propagates to the caller as a
* thrown, sanitized `AuthorizationEvaluationError`, exactly like Core's identity
* `resolvePrincipal` never silently downgrades an outage to "anonymous." Only a genuinely
* malformed or unrecognized permission/baseline/scope — a deterministic, expected input problem,
* not an infrastructure failure — resolves to `denied` without throwing.
*/
export function createAuthorizationEvaluator(
options: CreateAuthorizationEvaluatorOptions,
): AuthorizationEvaluator {
const now = options.now ?? (() => new Date());
return Object.freeze({
authorize: async (request: AuthorizeRequest): Promise<AuthorizationDecision> => {
const parsedBaseline = permissionBaselineSchema.safeParse(request.baseline);
if (!parsedBaseline.success) {
return AUTHORIZATION_DENIED;
}
// Shape-validated before any baseline is allowed to return `allowed` — a malformed
// permission id must deny under `public` and `authenticated` exactly as much as under
// `restricted`, even though neither of the first two ever reads `parsedPermission.data`.
// This is a pure, synchronous check: it never touches the grant or platform-owner ports, so
// `public`/`authenticated` still resolve with no database lookup at all.
const parsedPermission = safeParsePermissionId(request.permission);
if (!parsedPermission.success) {
return AUTHORIZATION_DENIED;
}
if (parsedBaseline.data === "public") {
return AUTHORIZATION_ALLOWED;
}
if (parsedBaseline.data === "authenticated") {
// A device deliberately does not satisfy this. The baseline means "a person is signed in",
// and every route that settles for it — the module UI inventory, anything a human is
// expected to be looking at — would otherwise open to an unattended screen (ADR 0036).
return isAuthenticatedPrincipal(request.principal)
? AUTHORIZATION_ALLOWED
: AUTHORIZATION_DENIED;
}
let scope: AuthorizationScope | undefined;
if (request.scope !== undefined) {
const parsedScope = safeParseAuthorizationScope(request.scope);
if (!parsedScope.success) {
return AUTHORIZATION_DENIED;
}
scope = parsedScope.data;
}
// A device satisfies `restricted` only by holding that exact permission, and by no other
// route: there is no owner check here, no baseline that stands in for a grant, and no branch
// that returns `allowed` before this lookup. "Paired" is not authority — a device that was
// paired for one display has nothing else.
if (isDevicePrincipal(request.principal)) {
if (!options.devices || scope !== undefined) {
// A scoped check has no device meaning yet; denying is the honest answer rather than
// silently treating it as unscoped.
return AUTHORIZATION_DENIED;
}
try {
return (await options.devices.hasActivePermission(
request.principal.deviceId,
parsedPermission.data,
))
? AUTHORIZATION_ALLOWED
: AUTHORIZATION_DENIED;
} catch {
throw new AuthorizationEvaluationError();
}
}
// parsedBaseline.data === "restricted": requires an authenticated principal AND
// (an active matching grant OR platform-owner authority). Baseline alone never satisfies it.
if (!isAuthenticatedPrincipal(request.principal)) {
return AUTHORIZATION_DENIED;
}
const userId = request.principal.userId;
let isOwner: boolean;
let hasGrant: boolean;
try {
[isOwner, hasGrant] = await Promise.all([
options.platformOwners.isPlatformOwner(userId),
options.grants.hasActiveGrant({
now: now(),
permission: parsedPermission.data,
userId,
...(scope === undefined ? {} : { scope }),
}),
]);
} catch {
throw new AuthorizationEvaluationError();
}
return isOwner || hasGrant ? AUTHORIZATION_ALLOWED : AUTHORIZATION_DENIED;
},
});
}
+7
View File
@@ -0,0 +1,7 @@
export * from "./bootstrap.js";
export * from "./database.js";
export * from "./evaluator.js";
export * from "./initial-owner.js";
export * from "./permissions.js";
export * from "./ports.js";
export * from "./store.js";
+323
View File
@@ -0,0 +1,323 @@
import type { EkklesiaUserId } from "@ekklesia/contracts";
import { safeParseEkklesiaUserId } from "@ekklesia/contracts";
import { and, eq, sql } from "drizzle-orm";
import type { BootstrapIdentityCreator } from "../identity/bootstrap-identity.js";
import { BootstrapIdentityError } from "../identity/bootstrap-identity.js";
import type { IdentityDrizzleDatabase } from "../identity/database.js";
import { ekklesiaCoreAccount, ekklesiaCoreUser } from "../identity/schema.js";
import { bootstrapPlatformOwner, PlatformOwnerBootstrapError } from "./bootstrap.js";
import type { AuthorizationDrizzleDatabase } from "./database.js";
import { ekklesiaCorePlatformOwnerBootstrap } from "./schema.js";
export type InitialPlatformOwnerErrorCode =
| "INITIAL_OWNER_ALREADY_COMPLETED"
| "INITIAL_OWNER_EMAIL_INVALID"
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
| "INITIAL_OWNER_IDENTITY_NOT_VERIFIED"
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
| "INITIAL_OWNER_PASSWORD_TOO_SHORT"
| "INITIAL_OWNER_SCHEMA_UNAVAILABLE";
const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, string>> = Object.freeze(
{
INITIAL_OWNER_ALREADY_COMPLETED:
"This installation already has a platform owner. Initial-owner bootstrap runs once and cannot run again.",
INITIAL_OWNER_EMAIL_INVALID: "The email address is not a valid address.",
INITIAL_OWNER_IDENTITY_NOT_CREATED:
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
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.",
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:
"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_SHORT: "The password is shorter than the identity system allows.",
INITIAL_OWNER_SCHEMA_UNAVAILABLE:
"The database is unreachable or has not been migrated. Apply migrations before creating the initial owner.",
},
);
/**
* Never carries the password, a hash, a connection string, SQL, a driver error, or a cause. The
* messages are written to be read by an operator at a terminal, so each one states what happened
* *and* what is still true about the installation — in particular whether bootstrap remains
* available, which is the only fact that determines what they should do next.
*/
export class InitialPlatformOwnerError extends Error {
readonly code: InitialPlatformOwnerErrorCode;
constructor(code: InitialPlatformOwnerErrorCode) {
super(initialOwnerMessages[code]);
this.name = "InitialPlatformOwnerError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
// A CLI-level advisory key distinct from `bootstrap.ts`'s: taking the same one would deadlock this
// operation against the grant it performs. This serializes whole invocations; that one serializes
// the grant transaction itself, and both remain in force.
const initialOwnerLockNamespace = 1_500_000_003;
const initialOwnerLockKey = 1_500_000_004;
export interface CreateInitialPlatformOwnerDependencies {
readonly authorizationDb: AuthorizationDrizzleDatabase;
readonly identityDb: IdentityDrizzleDatabase;
readonly createIdentity: BootstrapIdentityCreator;
/** Injected so a partial failure — identity created, ownership not granted — can be exercised.
* Production composition leaves it unset and the real single-use bootstrap is used. */
readonly grantOwnership?: (userId: EkklesiaUserId) => Promise<void>;
}
export interface CreateInitialPlatformOwnerInput {
readonly email: string;
readonly password: string;
/** The display name the identity model requires. */
readonly name: string;
}
export interface InitialPlatformOwnerResult {
readonly email: string;
readonly userId: EkklesiaUserId;
/** `created` when this call made the identity; `adopted` when one already existed for that
* address and only ownership was granted. Reported so an operator is never told something was
* created when it was not. */
readonly identity: "adopted" | "created";
}
interface ExistingIdentity {
readonly userId: string;
readonly hasCredential: boolean;
/** As the identity system actually stored it, which is not necessarily as the operator typed it. */
readonly storedEmail: string;
}
/**
* Finds an identity by email, case-insensitively.
*
* Deliberately not an exact match on the caller's spelling: Better Auth lowercases the address it
* stores (verified against the pinned release — `[email protected]` persists as
* `[email protected]`), so an exact comparison would fail to find the very account this operation
* had just created and would report it missing.
*/
async function findIdentityByEmail(
db: IdentityDrizzleDatabase,
email: string,
): Promise<ExistingIdentity | undefined> {
const rows = await db
.select({ email: ekklesiaCoreUser.email, id: ekklesiaCoreUser.id })
.from(ekklesiaCoreUser)
.where(sql`lower(${ekklesiaCoreUser.email}) = ${email.trim().toLowerCase()}`)
.limit(1);
const found = rows[0];
if (!found) {
return undefined;
}
// A row in `user` alone is not an account anyone can sign in as — the credential lives in
// `account`. The distinction matters: granting ownership to a passwordless row would consume the
// one-time bootstrap on an identity nobody can authenticate as, leaving the installation with an
// owner that cannot log in and no second chance.
const credentials = await db
.select({ id: ekklesiaCoreAccount.id })
.from(ekklesiaCoreAccount)
.where(
and(
eq(ekklesiaCoreAccount.userId, found.id),
eq(ekklesiaCoreAccount.providerId, "credential"),
),
)
.limit(1);
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 {
if (error instanceof BootstrapIdentityError) {
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_SHORT");
}
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG") {
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_LONG");
}
if (error.code === "BOOTSTRAP_IDENTITY_EMAIL_INVALID") {
return new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
}
}
return new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
}
/**
* Creates the first platform owner of a fresh installation: one identity, then the single-use
* ownership grant.
*
* ## Why the identity is verified rather than trusted
*
* Better Auth's sign-up returns a **fabricated success** when the address is already registered —
* a user object carrying an id that was never persisted, with the database left unchanged (verified
* against the pinned release). On a public endpoint that is correct: it refuses to tell an
* anonymous caller which addresses exist. Here it is a trap, because a caller that believed the
* response would report an owner it never created and then grant ownership to an id that does not
* exist. So the identity is always read back from the database, and that read — not the sign-up
* response — is what the grant uses.
*
* ## Why an existing identity is adopted rather than refused or overwritten
*
* If the address already has a usable account and bootstrap is still open, ownership is granted to
* it and **its password is left completely alone**. This is precisely the recovery path for the one
* partial failure this operation can have: sign-up commits through Better Auth's own connection and
* the grant runs in a separate transaction, so a crash between them leaves an identity with no
* ownership. Refusing would strand a fresh installation with no way forward but manual SQL; a
* password reset would be a silent takeover of an account the operator may not own. Adoption
* changes no credential, and it is reported as `adopted` so nobody is told an account was created
* when it was not.
*
* That is safe here specifically because production has **no** other way to create an identity —
* public signup cannot be enabled at all — so an account present on a fresh installation was put
* there by someone who already had database access.
*
* ## Ordering
*
* The bootstrap sentinel is checked before an identity is created, so a closed bootstrap never
* leaves a stray account behind. That check is an early exit, not the authority: `bootstrapPlatformOwner`
* re-checks it inside its own locked transaction, which is what actually makes the grant single-use.
*/
export async function createInitialPlatformOwner(
dependencies: CreateInitialPlatformOwnerDependencies,
input: CreateInitialPlatformOwnerInput,
): Promise<InitialPlatformOwnerResult> {
const email = input.email.trim();
if (email.length === 0) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
}
// Serializes entire invocations against each other. Without it two operators racing on a fresh
// installation would both pass the sentinel check and both create an identity, and only then
// would one lose the grant — leaving a stray account that can sign in and owns nothing. The grant
// alone was already safe; this makes the whole command safe.
return dependencies.authorizationDb.transaction(async (lockTx) => {
await lockTx.execute(
sql`select pg_advisory_xact_lock(${initialOwnerLockNamespace}, ${initialOwnerLockKey})`,
);
let alreadyConsumed: boolean;
try {
const consumed = await lockTx
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
.from(ekklesiaCorePlatformOwnerBootstrap)
.limit(1);
alreadyConsumed = consumed.length > 0;
} catch {
// An unreachable database and a database without the authorization tables are the same
// situation to an operator running this on a fresh server: the deployment step before this
// one has not happened. Never migrated implicitly — see ADR 0035.
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
}
if (alreadyConsumed) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
}
let existing: ExistingIdentity | undefined;
try {
existing = await findIdentityByEmail(dependencies.identityDb, email);
} catch {
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
}
if (existing && !existing.hasCredential) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_UNUSABLE");
}
// Captured before the read-back below, which cannot distinguish the two: an identity this call
// just created and one it adopted both come back with a credential.
const identity: "adopted" | "created" = existing ? "adopted" : "created";
if (!existing) {
try {
await dependencies.createIdentity({
email,
name: input.name,
password: input.password,
});
} catch (error) {
throw translateIdentityCreationError(error);
}
existing = await findIdentityByEmail(dependencies.identityDb, email).catch(() => undefined);
if (!existing) {
// Reached when sign-up reported success without persisting anything.
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
}
}
const parsedUserId = safeParseEkklesiaUserId(existing.userId);
if (!parsedUserId.success) {
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 {
const grant =
dependencies.grantOwnership ??
((userId: EkklesiaUserId) =>
bootstrapPlatformOwner(dependencies.authorizationDb, { userId }));
await grant(parsedUserId.data);
} catch (error) {
if (
error instanceof PlatformOwnerBootstrapError &&
error.code === "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
}
// The identity is real and remains usable; the sentinel rolled back with the grant, so
// re-running this command with the same address adopts it and finishes.
throw new InitialPlatformOwnerError("INITIAL_OWNER_OWNERSHIP_GRANT_FAILED");
}
// The stored address, never the operator's spelling: the identity system lowercases what it
// persists, and telling someone to sign in as `[email protected]` when the account is
// `[email protected]` invites them to conclude the command failed.
return Object.freeze({ email: existing.storedEmail, identity, userId: parsedUserId.data });
});
}
+222
View File
@@ -0,0 +1,222 @@
import {
type PermissionDeclaration,
type PermissionId,
parsePermissionId,
safeParsePermissionId,
} from "@ekklesia/contracts";
/**
* Core's registry consumes the one shared `PermissionDeclaration` shape
* (`packages/contracts/src/authorization.ts`) rather than declaring its own interface — a module
* manifest's `permissions` array parses into the exact same type, so a declaration a module ships
* and a declaration Core registers are provably interchangeable. Re-exported here so existing
* `@ekklesia/core/authorization` consumers keep importing it from this package.
*/
export type { PermissionDeclaration, PermissionDeclarationInput } from "@ekklesia/contracts";
export interface PermissionRegistry {
readonly declarations: readonly PermissionDeclaration[];
/** Returns the declaration for a permission id, or `undefined` for anything malformed or not
* registered — the two are deliberately indistinguishable to callers, so "unknown permission"
* and "invalid permission" fail closed the same way. */
get(permissionId: unknown): PermissionDeclaration | undefined;
has(permissionId: unknown): boolean;
}
export type PermissionRegistryErrorCode = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION";
/** Thrown only at composition time (registry construction), never per-request. Carries the
* offending permission id deliberately — this is a startup-time programmer/configuration error
* message, not a runtime response, and a namespaced permission id is not sensitive. */
export class PermissionRegistryError extends Error {
readonly code: PermissionRegistryErrorCode;
readonly permissionId: string;
constructor(permissionId: string) {
super(`Permission "${permissionId}" is already registered.`);
this.name = "PermissionRegistryError";
this.code = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION";
this.permissionId = permissionId;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* Builds an immutable permission registry from a fixed list of declarations, rejecting any
* duplicate permission id outright at composition time. Deliberately the smallest mechanism that
* distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no
* network or filesystem I/O. The module integration layer's composer
* (`composePlatformPermissionRegistry`, `core/src/modules/permissions.ts`) merges Core's own
* declarations with every discovered module's into one combined list and calls this same
* constructor, inheriting duplicate detection for free rather than reimplementing it.
*/
export function createPermissionRegistry(
declarations: readonly PermissionDeclaration[],
): PermissionRegistry {
const byId = new Map<PermissionId, PermissionDeclaration>();
for (const declaration of declarations) {
if (byId.has(declaration.id)) {
throw new PermissionRegistryError(declaration.id);
}
byId.set(declaration.id, Object.freeze({ ...declaration }));
}
const frozenDeclarations = Object.freeze([...byId.values()]);
return Object.freeze({
declarations: frozenDeclarations,
get(permissionId: unknown): PermissionDeclaration | undefined {
const parsed = safeParsePermissionId(permissionId);
return parsed.success ? byId.get(parsed.data) : undefined;
},
has(permissionId: unknown): boolean {
const parsed = safeParsePermissionId(permissionId);
return parsed.success && byId.has(parsed.data);
},
});
}
/** Restricted: delegated grant administration is itself a privileged action, never public or
* merely authenticated. A platform owner always passes the evaluator's existing owner bypass and
* can delegate this permission to another account deliberately via the explicit-grant API. */
export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
"core.authorization.grants.manage",
);
/**
* Authenticated: any signed-in user of this installation may learn which module UIs the platform is
* currently hosting, because that is exactly what a browser shell has to render before it can offer
* anything at all. It is deliberately not `public` — the anonymous artifact route (ADR 0025) is
* careful never to become an enumeration oracle, and an anonymous inventory would hand over the very
* listing that route withholds. It is deliberately not `restricted` either: a shell that rendered
* nothing until an operator granted every user this permission would make the platform unusable for
* its ordinary users, and the module UIs themselves already gate their own content on their own
* permissions.
*
* Listing a module UI is not permission to use that module. Every module route remains enforced
* against its own declared permission, unchanged.
*/
export const CORE_MODULES_UI_READ: PermissionId = parsePermissionId("core.modules.ui.read");
/**
* Restricted: reorganizing the Dashboard is an administrative action, not something every signed-in
* user may do. It is deliberately a **platform** permission and never a module's own — grouping is
* Ekklesia's presentation configuration, so no module may gate, grant, or influence it (ADR 0027).
*
* Holding it confers no module authority whatsoever. Moving a module into a folder changes a
* presentation row; it grants nobody access to that module's data, and every module route stays
* enforced against its own declared permission exactly as before.
*/
export const CORE_DASHBOARD_NAVIGATION_MANAGE: PermissionId = parsePermissionId(
"core.dashboard.navigation.manage",
);
/**
* Restricted, and it gates **reading as well as changing**. Deciding which installed modules run is
* an administrative operation, and the read is not a milder version of it: the lifecycle view is the
* only place a *disabled* module, a *blocked* module, and the required-dependency edges between them
* are visible at all. `core.modules.ui.read` deliberately exposes the mountable set to every signed-in
* user; this exposes the parts of the installation that are deliberately not running, which is not
* the same audience. A second, weaker read permission over the same resource would be ceremony
* (ADR 0027's reasoning, applied in the other direction).
*
* It is a **platform** permission and never a module's. A module can neither declare it, gate it, nor
* observe it: whether a module runs is a decision its host makes about it, not a decision it takes
* part in (ADR 0028). Holding it confers no authority inside any module — every module route stays
* enforced against that module's own declared permission.
*/
export const CORE_MODULES_LIFECYCLE_MANAGE: PermissionId = parsePermissionId(
"core.modules.lifecycle.manage",
);
/**
* Restricted, and deliberately **separate from `core.modules.lifecycle.manage`**. The two authorize
* genuinely different actions: lifecycle decides *whether a module runs at all*, while this decides
* *how a running module behaves*. Someone trusted to tune a slideshow's timing need not be trusted
* to take modules offline, and the reverse is equally true, so folding them into one permission
* would grant authority nobody asked for in whichever direction it was used (ADR 0029).
*
* It gates reading as well as writing, because the read returns the module's declared schema
* together with every stored value — endpoints, bucket names, and whatever else an operator has
* configured. That is deployment detail, not something every signed-in user needs.
*
* **It never exposes secret material.** A `secret` key's value is deployment-supplied, is never
* persisted, and is never returned by any route; the API reports only whether one is configured.
* Holding this permission therefore confers no ability to read a credential — there is no code path
* that would serve one.
*
* Like every other Core permission it is a **platform** permission a module can neither declare,
* gate, nor observe, and holding it confers no authority inside any module.
*/
export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId(
"core.modules.configuration.manage",
);
/**
* Restricted, and deliberately a **platform** permission rather than a module's.
*
* A device is an identity the platform issues, so deciding that a screen in the foyer may exist —
* approving its pairing, seeing what has been paired, and taking it away again — is authority over
* Ekklesia itself, not over the module whose display happens to be shown. Letting a module declare
* or gate it would mean whoever administers a slideshow could mint credentials that outlive it.
*
* It gates approval, listing, and revocation together. Splitting "see the paired devices" from
* "revoke one" would be ceremony: the same person is trusted with both, and a reader who cannot act
* on what they see is not a role anyone has asked for (ADR 0027's reasoning, applied again).
*
* Holding it confers **no** authority inside any module and no ability to read a device credential
* — none is stored in a recoverable form, so there is no code path that could serve one (ADR 0036).
*/
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage");
/**
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
* exactly these plus every discovered module's declarations, in a fixed, deterministic order,
* without reconstructing or guessing Core's own list. `CORE_PERMISSION_REGISTRY` below remains the
* registry Core-only code and existing tests use directly.
*/
export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([
Object.freeze({
baseline: "restricted" as const,
description: "Create, list, and revoke another user's explicit authorization grants.",
id: CORE_AUTHORIZATION_GRANTS_MANAGE,
}),
Object.freeze({
baseline: "authenticated" as const,
description: "List the installed module UIs a browser host may mount.",
id: CORE_MODULES_UI_READ,
}),
Object.freeze({
baseline: "restricted" as const,
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
}),
Object.freeze({
baseline: "restricted" as const,
description: "Read and change which installed modules are enabled.",
id: CORE_MODULES_LIFECYCLE_MANAGE,
}),
Object.freeze({
baseline: "restricted" as const,
description: "Read and change an installed module's own configuration values.",
id: CORE_MODULES_CONFIGURATION_MANAGE,
}),
Object.freeze({
baseline: "restricted" as const,
description: "Approve display device pairing, list paired devices, and revoke a device.",
id: CORE_DEVICES_MANAGE,
}),
]);
/**
* Core's own permission registry, containing exactly the Core-owned permissions declared above.
* Production `apps/api` does not consult this constant directly: it enforces requests against the
* composed platform registry (`composePlatformPermissionRegistry`,
* `core/src/modules/permissions.ts`), which merges these same declarations with every installed
* module's. `CORE_PERMISSION_REGISTRY` remains for Core-only callers and tests that need Core's
* permissions in isolation, without composing in any module.
*/
export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry(
CORE_PERMISSION_DECLARATIONS,
);
+47
View File
@@ -0,0 +1,47 @@
import type {
AuthorizationScope,
EkklesiaDeviceId,
EkklesiaUserId,
PermissionId,
} from "@ekklesia/contracts";
export interface ActiveGrantQuery {
readonly userId: EkklesiaUserId;
readonly permission: PermissionId;
/** Absent means an unscoped check. An unscoped grant satisfies either an unscoped or scoped
* check for the same permission; a scoped grant satisfies only the exact matching scope and
* never satisfies an unscoped check. */
readonly scope?: AuthorizationScope;
/** The instant to evaluate expiration against — injected so evaluation is deterministic and
* testable rather than reading the wall clock inside the lookup itself. */
readonly now: Date;
}
/**
* The only read dependency `createAuthorizationEvaluator` has for grant state. Narrow and
* Postgres-independent by design: a test can supply a fake implementation, and the evaluator
* itself never knows whether it is backed by `core/src/authorization/store.ts` or a test double.
*/
export interface AuthorizationGrantPort {
hasActiveGrant(query: ActiveGrantQuery): Promise<boolean>;
}
/** The only read dependency the evaluator has for platform-owner authority — deliberately a
* separate, narrower port from grants, since owner authority is not represented as a grant. */
export interface AuthorizationPlatformOwnerPort {
isPlatformOwner(userId: EkklesiaUserId): Promise<boolean>;
}
/**
* The evaluator's only read dependency for **device** authority.
*
* Separate from `AuthorizationGrantPort` because the two answer questions about different kinds of
* subject, not because devices are a special case in the decision: `authorize` consults exactly one
* of them per call, by principal kind, and neither can be reached without going through it.
*
* There is deliberately no device counterpart to `AuthorizationPlatformOwnerPort`. A device is
* never a platform owner, so there is no port that could accidentally say it was.
*/
export interface AuthorizationDevicePort {
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
}
+181
View File
@@ -0,0 +1,181 @@
import { relations, sql } from "drizzle-orm";
import { check, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { ekklesiaCoreUser } from "../identity/schema.js";
/**
* Core-owned authorization schema: grants (explicit or relationship-derived), platform-owner
* status, and an append-only audit log of every security-relevant mutation. Table and index names
* are explicit and namespaced so ownership is unambiguous inside the shared public PostgreSQL
* schema, exactly like `core/src/identity/schema.ts`. Foreign keys to `ekklesia_core_user` are
* safe here (unlike a module referencing another module's tables) because Core owns both sides.
*/
export const ekklesiaCoreGrant = pgTable(
"ekklesia_core_grant",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: text("user_id")
.notNull()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
permissionId: text("permission_id").notNull(),
scope: text("scope"),
sourceKind: text("source_kind").notNull(),
sourceRef: text("source_ref"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
createdByActorType: text("created_by_actor_type").notNull(),
createdByActorId: text("created_by_actor_id"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
revokedByActorType: text("revoked_by_actor_type"),
revokedByActorId: text("revoked_by_actor_id"),
},
(table) => [
index("ekklesia_core_grant_user_permission_idx").on(table.userId, table.permissionId),
check(
"ekklesia_core_grant_source_kind_check",
sql`${table.sourceKind} in ('explicit', 'relationship')`,
),
check(
"ekklesia_core_grant_created_actor_type_check",
sql`${table.createdByActorType} in ('user', 'system')`,
),
check(
"ekklesia_core_grant_revoked_actor_type_check",
sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`,
),
],
);
export const ekklesiaCorePlatformOwner = pgTable(
"ekklesia_core_platform_owner",
{
userId: text("user_id")
.primaryKey()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
grantedByActorType: text("granted_by_actor_type").notNull(),
grantedByActorId: text("granted_by_actor_id"),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
revokedByActorType: text("revoked_by_actor_type"),
revokedByActorId: text("revoked_by_actor_id"),
},
(table) => [
check(
"ekklesia_core_platform_owner_granted_actor_type_check",
sql`${table.grantedByActorType} in ('user', 'system')`,
),
check(
"ekklesia_core_platform_owner_revoked_actor_type_check",
sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`,
),
],
);
/**
* Append-only *by application convention*: Core's authorization store exposes no update or delete
* path for these rows. That is a property of this codebase, not a database guarantee — no
* PostgreSQL role, trigger, or rule prevents a database administrator, a future migration, or
* anything else holding the same credentials from modifying them. `scope` intentionally rejects an
* empty (but non-null) string — defense in depth matching the application-layer
* `AuthorizationScope` contract, which never produces one — so a coding defect that slipped an
* empty scope past that validation fails the whole grant-plus-audit transaction atomically instead
* of recording a meaningless audit entry.
*
* `subject_user_id` is a **retained historical snapshot**, deliberately carrying no foreign key to
* the live Better Auth user row. It previously cascaded on user deletion, which meant deleting a
* user destroyed exactly the security history describing what that user had been granted and who
* granted it — the audit trail disappeared precisely when it mattered most. The value stays a
* plain, non-null identifier string; no name, email, or other profile data is ever copied here.
*/
export const ekklesiaCoreAuthorizationAudit = pgTable(
"ekklesia_core_authorization_audit",
{
id: uuid("id").primaryKey().defaultRandom(),
action: text("action").notNull(),
actorType: text("actor_type").notNull(),
actorId: text("actor_id"),
subjectUserId: text("subject_user_id").notNull(),
permissionId: text("permission_id"),
scope: text("scope"),
grantId: uuid("grant_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("ekklesia_core_authorization_audit_subject_idx").on(table.subjectUserId),
check(
"ekklesia_core_authorization_audit_action_check",
sql`${table.action} in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')`,
),
check(
"ekklesia_core_authorization_audit_actor_type_check",
sql`${table.actorType} in ('user', 'system')`,
),
check(
"ekklesia_core_authorization_audit_scope_check",
sql`${table.scope} is null or length(${table.scope}) > 0`,
),
],
);
/**
* The durable, single-row record meaning "platform-owner bootstrap was successfully consumed."
*
* Bootstrap's single-use authority previously came from the mere existence of a row in
* `ekklesia_core_platform_owner` — which cascades on user deletion, so deleting the bootstrapped
* owner's user row silently reopened the one-time operation that establishes the platform's first
* administrator. This table exists specifically so that authority survives owner revocation and
* user deletion: it has **no foreign key to any user**, and nothing in Core ever deletes from it.
*
* `id` is pinned by a check constraint to the single literal value `'singleton'`, so the primary
* key makes a second row structurally impossible rather than merely unwritten by convention —
* concurrent first attempts collide on the primary key inside their transactions.
*
* `consumed_for_user_id` is an optional historical snapshot of who bootstrap made an owner, in the
* same retained-identifier style as the audit subject and carrying no profile data. It is null for
* an installation whose consumed state was backfilled by migration, because a backfill cannot know
* which of several historical owner rows the original single use actually targeted, and guessing
* one would fabricate history.
*/
export const ekklesiaCorePlatformOwnerBootstrap = pgTable(
"ekklesia_core_platform_owner_bootstrap",
{
id: text("id").primaryKey(),
consumedAt: timestamp("consumed_at", { withTimezone: true }).defaultNow().notNull(),
consumedForUserId: text("consumed_for_user_id"),
},
(table) => [
check("ekklesia_core_platform_owner_bootstrap_singleton_check", sql`${table.id} = 'singleton'`),
],
);
/** The only value `ekklesia_core_platform_owner_bootstrap.id` may ever hold. */
export const PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID = "singleton";
export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({
user: one(ekklesiaCoreUser, {
fields: [ekklesiaCoreGrant.userId],
references: [ekklesiaCoreUser.id],
}),
}));
export const ekklesiaCorePlatformOwnerRelations = relations(
ekklesiaCorePlatformOwner,
({ one }) => ({
user: one(ekklesiaCoreUser, {
fields: [ekklesiaCorePlatformOwner.userId],
references: [ekklesiaCoreUser.id],
}),
}),
);
// `ekklesiaCoreAuthorizationAudit` deliberately declares no `relations(...)` to the user table:
// its subject is a retained historical identifier that may name a user row that no longer exists,
// so modelling it as a live relation would misdescribe the data.
/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */
export const authorizationSchema = {
authorizationAudit: ekklesiaCoreAuthorizationAudit,
grant: ekklesiaCoreGrant,
platformOwner: ekklesiaCorePlatformOwner,
platformOwnerBootstrap: ekklesiaCorePlatformOwnerBootstrap,
} as const;
+654
View File
@@ -0,0 +1,654 @@
import type {
AuthorizationActor,
AuthorizationScope,
EkklesiaUserId,
GrantId,
GrantSourceKind,
GrantSourceRef,
PermissionId,
} from "@ekklesia/contracts";
import {
isUserActor,
parseAuthorizationScope,
parseEkklesiaUserId,
parseGrantId,
parsePermissionId,
} from "@ekklesia/contracts";
import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm";
import type { AuthorizationDrizzleDatabase } from "./database.js";
import type {
ActiveGrantQuery,
AuthorizationGrantPort,
AuthorizationPlatformOwnerPort,
} from "./ports.js";
import {
ekklesiaCoreAuthorizationAudit,
ekklesiaCoreGrant,
ekklesiaCorePlatformOwner,
} from "./schema.js";
export type AuthorizationStoreErrorCode =
| "AUTHORIZATION_GRANT_ALREADY_EXISTS"
| "AUTHORIZATION_GRANT_CREATE_FAILED"
| "AUTHORIZATION_GRANT_EXPIRATION_INVALID"
| "AUTHORIZATION_GRANT_LIMIT_INVALID"
| "AUTHORIZATION_GRANT_LIST_FAILED"
| "AUTHORIZATION_GRANT_LOOKUP_FAILED"
| "AUTHORIZATION_GRANT_NOT_FOUND"
| "AUTHORIZATION_GRANT_REVOKE_FAILED"
| "AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED"
| "AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED"
| "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND";
const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({
AUTHORIZATION_GRANT_ALREADY_EXISTS:
"An equivalent authorization grant is already effective for this user.",
AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.",
AUTHORIZATION_GRANT_EXPIRATION_INVALID:
"The authorization grant expiration must be a valid instant in the future.",
AUTHORIZATION_GRANT_LIMIT_INVALID: "The authorization grant page size is invalid.",
AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.",
AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.",
AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.",
AUTHORIZATION_GRANT_REVOKE_FAILED: "The authorization grant could not be revoked.",
AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED: "The platform-owner lookup could not be completed.",
AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED: "The platform-owner change could not be recorded.",
AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND:
"The platform-owner record was not found or is already revoked.",
});
/** The largest page any caller may request from `listGrantsForUser`, enforced identically by the
* store and by the HTTP surface so there is exactly one bound, not two that could drift. */
export const AUTHORIZATION_GRANT_LIST_MAX_LIMIT = 100;
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
export class AuthorizationStoreError extends Error {
readonly code: AuthorizationStoreErrorCode;
constructor(code: AuthorizationStoreErrorCode) {
super(storeErrorMessages[code]);
this.name = "AuthorizationStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function isAuthorizationStoreError(error: unknown): error is AuthorizationStoreError {
return error instanceof AuthorizationStoreError;
}
export interface CreateGrantInput {
readonly userId: EkklesiaUserId;
readonly permission: PermissionId;
readonly scope?: AuthorizationScope;
readonly sourceKind: GrantSourceKind;
readonly sourceRef?: GrantSourceRef;
readonly actor: AuthorizationActor;
/** Absent means the grant never expires. */
readonly expiresAt?: Date;
}
export interface GrantRecord {
readonly id: GrantId;
readonly userId: EkklesiaUserId;
readonly permission: PermissionId;
readonly scope?: AuthorizationScope;
readonly sourceKind: GrantSourceKind;
readonly sourceRef?: GrantSourceRef;
readonly createdAt: Date;
readonly expiresAt?: Date;
readonly revokedAt?: Date;
}
export interface RevokeGrantInput {
readonly grantId: GrantId;
readonly actor: AuthorizationActor;
/** When provided, the revoke only matches a grant whose `sourceKind` equals this value — a
* grant of a different source kind is treated exactly like a nonexistent one
* (`AUTHORIZATION_GRANT_NOT_FOUND`). The generic explicit-grant HTTP API always passes
* `"explicit"` here, so it can never revoke a relationship-derived grant regardless of caller
* intent; internal Core callers that omit this may revoke any grant, matching pass 1's
* original, unconstrained behavior. */
readonly sourceKind?: GrantSourceKind;
}
/** One row's position in the `created_at DESC, id DESC` ordering, used as a strict keyset
* boundary. Both values are required: comparing on the timestamp alone would skip or repeat rows
* whenever two grants share a `created_at`, which concurrent creation makes routine. */
export interface GrantPageBoundary {
readonly createdAt: Date;
readonly id: GrantId;
}
export interface ListGrantsForUserOptions {
/** Required. The maximum number of rows to return, 1 through
* `AUTHORIZATION_GRANT_LIST_MAX_LIMIT`. There is deliberately no default and no unbounded
* fallback anywhere: an unbounded listing returned a user's entire authorization history in one
* response, and a bound a caller may simply omit is not a bound. */
readonly limit: number;
/** When provided, only grants strictly *after* this position in the ordering are returned. */
readonly after?: GrantPageBoundary;
/** When provided, only grants with this exact `sourceKind` are returned. */
readonly sourceKind?: GrantSourceKind;
}
export interface GrantPage {
readonly grants: readonly GrantRecord[];
/** The last returned row's position when more rows exist, otherwise `undefined`. */
readonly nextBoundary?: GrantPageBoundary;
}
export interface CreateAuthorizationStoreOptions {
/** Injected only so expiration validation is deterministic under test. Production composition
* leaves this unset and the store reads real time. */
readonly now?: () => Date;
}
/**
* Core's Postgres-backed authorization persistence: grants, platform-owner status, and the
* append-only audit log, all behind one explicit factory — no environment reads, no connection
* attempt, and no query merely from importing this module or calling this factory; every method
* call is the only thing that ever touches PostgreSQL. Implements the narrow read ports
* `createAuthorizationEvaluator` depends on directly, so the same store instance is both Core's
* write API and the evaluator's read dependency.
*/
export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort {
createGrant(input: CreateGrantInput): Promise<GrantRecord>;
revokeGrant(input: RevokeGrantInput): Promise<void>;
/** One bounded page of the user's grants, active or revoked, ordered `created_at DESC, id DESC`.
* Never includes another user's grants. */
listGrantsForUser(userId: EkklesiaUserId, options: ListGrantsForUserOptions): Promise<GrantPage>;
grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
}
function actorId(actor: AuthorizationActor): string | null {
return isUserActor(actor) ? actor.userId : null;
}
interface GrantRow {
readonly id: string;
readonly userId: string;
readonly permissionId: string;
readonly scope: string | null;
readonly sourceKind: string;
readonly sourceRef: string | null;
readonly createdAt: Date;
readonly expiresAt: Date | null;
readonly revokedAt: Date | null;
}
/** Re-validates a row this same store just wrote, rather than trusting raw column values by
* construction — defense in depth against a future schema/migration drift, matching the
* paranoid-by-default posture the rest of Core's persistence code already follows. */
function toGrantRecord(row: GrantRow): GrantRecord {
return Object.freeze({
createdAt: row.createdAt,
id: parseGrantId(row.id),
permission: parsePermissionId(row.permissionId),
...(row.scope === null ? {} : { scope: parseAuthorizationScope(row.scope) }),
...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt }),
...(row.revokedAt === null ? {} : { revokedAt: row.revokedAt }),
sourceKind: row.sourceKind as GrantSourceKind,
...(row.sourceRef === null ? {} : { sourceRef: row.sourceRef }),
userId: parseEkklesiaUserId(row.userId),
});
}
/**
* The five values that make two grant issuances the *same authorization source*: subject,
* permission, scope, source kind, and source reference — with `NULL` equal only to `NULL`.
* `expires_at`, creation metadata, actor metadata, and the grant id deliberately do **not**
* distinguish them: re-issuing the identical authority with a different expiry or by a different
* administrator is the same grant, not a second one.
*/
interface GrantEquivalence {
readonly userId: string;
readonly permissionId: string;
readonly scope: string | null;
readonly sourceKind: string;
readonly sourceRef: string | null;
}
function equivalenceOf(input: CreateGrantInput): GrantEquivalence {
return {
permissionId: input.permission,
scope: input.scope ?? null,
sourceKind: input.sourceKind,
sourceRef: input.sourceRef ?? null,
userId: input.userId,
};
}
/**
* Serializes every create/revoke touching one equivalence tuple against a transaction-scoped
* PostgreSQL advisory lock, so concurrent callers cannot both observe "no effective equivalent"
* and both insert. A process-local mutex would not do this — nothing constrains the number of API
* processes — and neither would an unlocked check-then-insert.
*
* The key is a 64-bit hash of the tuple encoded as a JSON array, which keeps `NULL` and the empty
* string distinct and makes separator forgery through a crafted scope or source reference
* impossible. A hash collision is harmless by construction: it can only make two unrelated tuples
* serialize against each other, never make a real conflict go unnoticed, because the authoritative
* decision is always the exact SQL predicate below and never the hash.
*
* `pg_advisory_xact_lock(bigint)` uses PostgreSQL's *single-key* advisory-lock space, which is
* verified to be disjoint from the two-key space used by the migration runner
* (`core/src/migrations/postgres-database.ts`) and by platform-owner bootstrap. Grant serialization
* therefore can never block, or be blocked by, migration or bootstrap locking. Being
* transaction-scoped, it is released on COMMIT or ROLLBACK with no manual unlock.
*/
async function lockEquivalence(
tx: Parameters<Parameters<AuthorizationDrizzleDatabase["transaction"]>[0]>[0],
equivalence: GrantEquivalence,
): Promise<void> {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(jsonb_build_array(
${equivalence.userId}::text,
${equivalence.permissionId}::text,
${equivalence.scope}::text,
${equivalence.sourceKind}::text,
${equivalence.sourceRef}::text
)::text, 0))`,
);
}
/** Matches exactly the rows sharing an equivalence tuple. `is not distinct from` is what gives
* `NULL = NULL` for the two nullable members; plain `=` would silently match nothing. */
function equivalenceCondition(equivalence: GrantEquivalence) {
return and(
eq(ekklesiaCoreGrant.userId, equivalence.userId),
eq(ekklesiaCoreGrant.permissionId, equivalence.permissionId),
sql`${ekklesiaCoreGrant.scope} is not distinct from ${equivalence.scope}::text`,
eq(ekklesiaCoreGrant.sourceKind, equivalence.sourceKind),
sql`${ekklesiaCoreGrant.sourceRef} is not distinct from ${equivalence.sourceRef}::text`,
);
}
/** A supplied expiration must be a real instant strictly later than the acceptance clock. A past
* or equal-to-now expiration produces a grant that is dead the moment it is written, which is
* never what a caller meant; rejecting it here means trusted internal callers get the same
* guarantee the HTTP surface enforces, rather than each validating separately. */
function assertValidExpiration(expiresAt: Date | undefined, now: Date): void {
if (expiresAt === undefined) {
return;
}
if (
!(expiresAt instanceof Date) ||
Number.isNaN(expiresAt.getTime()) ||
expiresAt.getTime() <= now.getTime()
) {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_EXPIRATION_INVALID");
}
}
function assertValidLimit(limit: number): number {
if (
typeof limit !== "number" ||
!Number.isSafeInteger(limit) ||
limit < 1 ||
limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT
) {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIMIT_INVALID");
}
return limit;
}
export function createAuthorizationStore(
db: AuthorizationDrizzleDatabase,
options: CreateAuthorizationStoreOptions = {},
): AuthorizationStore {
const now = options.now ?? ((): Date => new Date());
return Object.freeze({
createGrant: async (input: CreateGrantInput): Promise<GrantRecord> => {
// Validated before any transaction opens, so a rejected expiration performs no database work
// at all and can never leave a grant or an audit row behind.
assertValidExpiration(input.expiresAt, now());
const equivalence = equivalenceOf(input);
try {
return await db.transaction(async (tx) => {
await lockEquivalence(tx, equivalence);
// Under the lock, "currently effective" is authoritative: an equivalent that is revoked,
// or whose expiry has already passed, does not block a fresh issuance.
const effective = await tx
.select({ id: ekklesiaCoreGrant.id })
.from(ekklesiaCoreGrant)
.where(
and(
equivalenceCondition(equivalence),
isNull(ekklesiaCoreGrant.revokedAt),
or(
isNull(ekklesiaCoreGrant.expiresAt),
gt(ekklesiaCoreGrant.expiresAt, sql`now()`),
),
),
)
.limit(1);
if (effective.length > 0) {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS");
}
const [row] = await tx
.insert(ekklesiaCoreGrant)
.values({
createdByActorId: actorId(input.actor),
createdByActorType: input.actor.type,
expiresAt: input.expiresAt ?? null,
permissionId: input.permission,
scope: input.scope ?? null,
sourceKind: input.sourceKind,
sourceRef: input.sourceRef ?? null,
userId: input.userId,
})
.returning();
if (!row) {
throw new Error("grant insert returned no row");
}
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
action: "grant.create",
actorId: actorId(input.actor),
actorType: input.actor.type,
grantId: row.id,
permissionId: input.permission,
scope: input.scope ?? null,
subjectUserId: input.userId,
});
return toGrantRecord(row);
});
} catch (error) {
if (isAuthorizationStoreError(error)) {
throw error;
}
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED");
}
},
/**
* Revoking one grant revokes *every* currently unrevoked issuance sharing its equivalence
* tuple, in one transaction, with one `grant.revoke` audit row per grant actually changed.
*
* Addressing a single id and revoking only that row was actively misleading: an operator who
* revoked a grant received `success` while an equivalent duplicate kept authorizing the same
* user. Expired-but-unrevoked equivalents are included deliberately — they are already
* ineffective, but leaving them unrevoked would keep the history in two states for what is one
* authorization source. Grants with a different subject, permission, scope, source kind, or
* source reference are untouched.
*/
revokeGrant: async (input: RevokeGrantInput): Promise<void> => {
try {
await db.transaction(async (tx) => {
// This first read exists only to discover the equivalence tuple, which is safe to read
// before the lock because those columns are immutable once written. It deliberately does
// *not* settle whether the revocation may proceed: everything it observed can change
// while this transaction waits for the lock below.
const [target] = await tx
.select({
permissionId: ekklesiaCoreGrant.permissionId,
scope: ekklesiaCoreGrant.scope,
sourceKind: ekklesiaCoreGrant.sourceKind,
sourceRef: ekklesiaCoreGrant.sourceRef,
userId: ekklesiaCoreGrant.userId,
})
.from(ekklesiaCoreGrant)
.where(
and(
eq(ekklesiaCoreGrant.id, input.grantId),
isNull(ekklesiaCoreGrant.revokedAt),
...(input.sourceKind === undefined
? []
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
),
)
.limit(1);
if (!target) {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
}
await lockEquivalence(tx, target);
// Revalidate the *addressed* grant now that the lock is held. Without this, a revocation
// that paused between the read above and the lock could wake after its target was
// revoked and a fresh equivalent created, and the tuple-matching update below would
// silently revoke that replacement — reporting success for a grant the caller never
// addressed, in an order no serial execution could produce, and losing the not-found
// result an already-revoked target must return.
//
// The check is deliberately narrow and re-asserts every condition the pre-lock read
// relied on: the exact addressed id, still unrevoked, still satisfying the caller's
// explicit/relationship source guard, and still carrying the same equivalence tuple the
// lock was taken for. It is a plain read, not `for update`: the tuple advisory lock
// remains the one create/revoke serialization order, so no second lock ordering — and no
// deadlock path — is introduced.
const [addressed] = await tx
.select({ id: ekklesiaCoreGrant.id })
.from(ekklesiaCoreGrant)
.where(
and(
eq(ekklesiaCoreGrant.id, input.grantId),
isNull(ekklesiaCoreGrant.revokedAt),
...(input.sourceKind === undefined
? []
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
equivalenceCondition(target),
),
)
.limit(1);
if (!addressed) {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
}
// Only now is the whole equivalence class cleared, which is what still removes genuine
// legacy duplicates — including expired-but-unrevoked ones. Narrowing this update to the
// addressed id instead would not have been a fix; it would have dropped that behavior.
const rows = await tx
.update(ekklesiaCoreGrant)
.set({
revokedAt: sql`now()`,
revokedByActorId: actorId(input.actor),
revokedByActorType: input.actor.type,
})
.where(and(equivalenceCondition(target), isNull(ekklesiaCoreGrant.revokedAt)))
.returning();
if (rows.length === 0) {
// Unreachable while the lock is held, since the row confirmed above satisfies this
// same predicate — kept as a fail-closed guard against a writer that bypasses the
// lock, rather than silently auditing nothing.
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
}
await tx.insert(ekklesiaCoreAuthorizationAudit).values(
rows.map((row) => ({
action: "grant.revoke",
actorId: actorId(input.actor),
actorType: input.actor.type,
grantId: row.id,
permissionId: row.permissionId,
scope: row.scope,
subjectUserId: row.userId,
})),
);
});
} catch (error) {
if (isAuthorizationStoreError(error)) {
throw error;
}
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED");
}
},
hasActiveGrant: async (query: ActiveGrantQuery): Promise<boolean> => {
try {
const scopeCondition =
query.scope === undefined
? isNull(ekklesiaCoreGrant.scope)
: or(isNull(ekklesiaCoreGrant.scope), eq(ekklesiaCoreGrant.scope, query.scope));
const rows = await db
.select({ id: ekklesiaCoreGrant.id })
.from(ekklesiaCoreGrant)
.where(
and(
eq(ekklesiaCoreGrant.userId, query.userId),
eq(ekklesiaCoreGrant.permissionId, query.permission),
scopeCondition,
isNull(ekklesiaCoreGrant.revokedAt),
or(isNull(ekklesiaCoreGrant.expiresAt), gt(ekklesiaCoreGrant.expiresAt, query.now)),
),
)
.limit(1);
return rows.length > 0;
} catch {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LOOKUP_FAILED");
}
},
/**
* Deterministic keyset pagination over `created_at DESC, id DESC`. The boundary is a strict
* row-value comparison on *both* columns, so grants sharing a `created_at` — which concurrent
* creation makes routine — can neither be skipped nor repeated across pages. Offset pagination
* would do exactly that under concurrent writes, which is why it is not used.
*
* At most `limit + 1` rows are read: the extra row only answers "is there another page" and is
* never returned.
*/
listGrantsForUser: async (
userId: EkklesiaUserId,
listOptions: ListGrantsForUserOptions,
): Promise<GrantPage> => {
const limit = assertValidLimit(listOptions.limit);
const after = listOptions.after;
try {
const rows = await db
.select()
.from(ekklesiaCoreGrant)
.where(
and(
eq(ekklesiaCoreGrant.userId, userId),
...(listOptions.sourceKind === undefined
? []
: [eq(ekklesiaCoreGrant.sourceKind, listOptions.sourceKind)]),
...(after === undefined
? []
: [
sql`(${ekklesiaCoreGrant.createdAt}, ${ekklesiaCoreGrant.id}) < (${after.createdAt}::timestamptz, ${after.id}::uuid)`,
]),
),
)
.orderBy(desc(ekklesiaCoreGrant.createdAt), desc(ekklesiaCoreGrant.id))
.limit(limit + 1);
const page = rows.slice(0, limit).map(toGrantRecord);
const last = page.at(-1);
return Object.freeze({
grants: Object.freeze(page),
...(rows.length > limit && last !== undefined
? { nextBoundary: Object.freeze({ createdAt: last.createdAt, id: last.id }) }
: {}),
});
} catch (error) {
if (isAuthorizationStoreError(error)) {
throw error;
}
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED");
}
},
grantPlatformOwner: async (
userId: EkklesiaUserId,
actor: AuthorizationActor,
): Promise<void> => {
try {
await db.transaction(async (tx) => {
await tx
.insert(ekklesiaCorePlatformOwner)
.values({
grantedByActorId: actorId(actor),
grantedByActorType: actor.type,
userId,
})
.onConflictDoUpdate({
set: {
grantedAt: sql`now()`,
grantedByActorId: actorId(actor),
grantedByActorType: actor.type,
revokedAt: null,
revokedByActorId: null,
revokedByActorType: null,
},
target: ekklesiaCorePlatformOwner.userId,
});
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
action: "platform_owner.grant",
actorId: actorId(actor),
actorType: actor.type,
subjectUserId: userId,
});
});
} catch {
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED");
}
},
revokePlatformOwner: async (
userId: EkklesiaUserId,
actor: AuthorizationActor,
): Promise<void> => {
try {
await db.transaction(async (tx) => {
const [row] = await tx
.update(ekklesiaCorePlatformOwner)
.set({
revokedAt: sql`now()`,
revokedByActorId: actorId(actor),
revokedByActorType: actor.type,
})
.where(
and(
eq(ekklesiaCorePlatformOwner.userId, userId),
isNull(ekklesiaCorePlatformOwner.revokedAt),
),
)
.returning();
if (!row) {
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND");
}
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
action: "platform_owner.revoke",
actorId: actorId(actor),
actorType: actor.type,
subjectUserId: userId,
});
});
} catch (error) {
if (isAuthorizationStoreError(error)) {
throw error;
}
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED");
}
},
isPlatformOwner: async (userId: EkklesiaUserId): Promise<boolean> => {
try {
const rows = await db
.select({ userId: ekklesiaCorePlatformOwner.userId })
.from(ekklesiaCorePlatformOwner)
.where(
and(
eq(ekklesiaCorePlatformOwner.userId, userId),
isNull(ekklesiaCorePlatformOwner.revokedAt),
),
)
.limit(1);
return rows.length > 0;
} catch {
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED");
}
},
});
}
+27
View File
@@ -0,0 +1,27 @@
import { formatMigrationApplyReport } from "./migration-report.js";
import { discoverRepositoryMigrations } from "./migration-command-support.js";
import { reportMigrationCommandError } from "./migration-command-errors.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
import { applyModuleMigrations, createPostgresMigrationDatabase } from "../migrations/index.js";
async function main(): Promise<void> {
const aggregate = await discoverRepositoryMigrations();
const databaseUrl = process.env.DATABASE_URL;
// Resolved and asserted before any client exists: an unsafe production transport fails
// here rather than after opening a connection.
const transport =
typeof databaseUrl === "string" ? resolveExecutableDatabaseTransport(databaseUrl) : undefined;
const database = createPostgresMigrationDatabase(databaseUrl, {
...(transport === undefined ? {} : { transport }),
});
const result = await applyModuleMigrations({ aggregate, database });
for (const line of formatMigrationApplyReport(result)) {
console.log(line);
}
}
main().catch((error: unknown) => {
reportMigrationCommandError(error);
process.exitCode = 1;
});
+111
View File
@@ -0,0 +1,111 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { safeParseEkklesiaUserId } from "@ekklesia/contracts";
import {
bootstrapPlatformOwner,
createAuthorizationDrizzleDatabase,
PlatformOwnerBootstrapError,
} from "../authorization/index.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
class BootstrapCliError extends Error {
constructor(message: string) {
super(message);
this.name = "BootstrapCliError";
}
}
const usage =
"Usage: pnpm authorization:bootstrap-owner -- --user-id <id> --confirm-user-id <same-id>";
interface ParsedArgs {
readonly confirmUserId: string;
readonly userId: string;
}
/** A minimal, hand-rolled flag parser — this command takes exactly two required, named flags, so
* a general-purpose argument-parsing dependency is not warranted. */
function parseArgs(argv: readonly string[]): ParsedArgs {
let userId: string | undefined;
let confirmUserId: string | undefined;
for (let index = 0; index < argv.length; index += 1) {
const flag = argv[index];
if (flag === "--user-id") {
userId = argv[index + 1];
index += 1;
} else if (flag === "--confirm-user-id") {
confirmUserId = argv[index + 1];
index += 1;
}
}
if (userId === undefined || confirmUserId === undefined) {
throw new BootstrapCliError(usage);
}
return { confirmUserId, userId };
}
async function main(): Promise<void> {
const { confirmUserId, userId: rawUserId } = parseArgs(process.argv.slice(2));
// Deliberately not "infer the first user" or "select by signup order" — the operator must name
// the exact, opaque Ekklesia user id twice, and both must match exactly, before anything else
// is even attempted.
if (rawUserId !== confirmUserId) {
throw new BootstrapCliError(
"--user-id and --confirm-user-id must match exactly. No changes were made.",
);
}
const parsedUserId = safeParseEkklesiaUserId(rawUserId);
if (!parsedUserId.success) {
throw new BootstrapCliError("The provided user id is not a valid Ekklesia user id.");
}
// Read only when this command actually executes — never merely by importing this module (see
// isDirectExecution() below) or any module it imports.
const databaseUrl = process.env.DATABASE_URL;
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
throw new BootstrapCliError("DATABASE_URL is required.");
}
// Asserted before the connection is constructed, so an unsafe production transport never opens
// a client at all.
const transport = resolveExecutableDatabaseTransport(databaseUrl);
const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
transport,
});
try {
await bootstrapPlatformOwner(db, { userId: parsedUserId.data });
console.log(`Platform-owner bootstrap complete for user "${parsedUserId.data}".`);
} finally {
await close().catch(() => undefined);
}
}
function reportBootstrapCliError(error: unknown): void {
if (error instanceof BootstrapCliError) {
console.error(error.message);
return;
}
if (error instanceof PlatformOwnerBootstrapError) {
console.error(`[${error.code}] ${error.message}`);
return;
}
console.error("Platform-owner bootstrap failed unexpectedly.");
}
function isDirectExecution(): boolean {
const entrypoint = process.argv[1];
return (
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
);
}
if (isDirectExecution()) {
main().catch((error: unknown) => {
reportBootstrapCliError(error);
process.exitCode = 1;
});
}
+233
View File
@@ -0,0 +1,233 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
createAuthorizationDrizzleDatabase,
createInitialPlatformOwner,
InitialPlatformOwnerError,
} from "../authorization/index.js";
import {
createBootstrapIdentityCreator,
createIdentityDrizzleDatabase,
} from "../identity/index.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
/**
* Creates the first platform owner of a fresh installation — one identity, then the single-use
* ownership grant — so a freshly deployed Ekklesia has a supported way to get its first
* administrator (ADR 0035).
*
* This exists because the login page has nothing to log in to on a new installation. Public signup
* is disabled in every environment and production cannot enable it at all, which is correct and is
* not changed here; the consequence was simply that no supported path created the first account.
* This is that path, and it is deliberately the same shape as `pnpm authorization:bootstrap-owner`
* and `pnpm modules:enable`: explicit, operator-invoked, never HTTP-exposed, never inferred, and
* reachable only by someone who already holds the database credentials.
*
* It creates no HTTP route, starts no server, and applies no migration.
*/
class InitialOwnerCliError extends Error {
constructor(message: string) {
super(message);
this.name = "InitialOwnerCliError";
this.stack = `${this.name}: ${this.message}`;
}
}
const usage =
"Usage: pnpm identity:create-initial-owner -- --email <address> [--name <display name>]\n" +
"The password is never taken from the command line. It is prompted for when a terminal is\n" +
"attached, and otherwise read from standard input:\n" +
" printf '%s' \"$PASSWORD\" | pnpm identity:create-initial-owner -- --email [email protected]";
interface ParsedArgs {
readonly email: string;
readonly name?: string;
}
/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts` and
* `set-module-enabled.ts`. There is deliberately no `--password`: see `readPassword`. */
function parseArgs(argv: readonly string[]): ParsedArgs {
let email: string | undefined;
let name: string | undefined;
for (let index = 0; index < argv.length; index += 1) {
const flag = argv[index];
if (flag === "--email") {
email = argv[index + 1];
index += 1;
} else if (flag === "--name") {
name = argv[index + 1];
index += 1;
} else if (flag === "--password" || flag?.startsWith("--password=") === true) {
// Refused explicitly rather than ignored, so an operator who tries it learns why instead of
// wondering why their password was not used.
throw new InitialOwnerCliError(
"--password is not supported: a command-line password is visible in shell history and in the process list.\n" +
usage,
);
}
}
if (email === undefined || email.trim().length === 0) {
throw new InitialOwnerCliError(usage);
}
return name === undefined ? { email } : { email, name };
}
/** Reads one line without echoing it. No dependency is added for this: the command needs exactly
* one hidden field, and Node's own raw-mode stdin is enough. */
function promptHidden(prompt: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
const input = process.stdin;
process.stdout.write(prompt);
input.setRawMode(true);
input.resume();
input.setEncoding("utf8");
let value = "";
const finish = (settle: () => void): void => {
input.removeListener("data", onData);
input.setRawMode(false);
input.pause();
process.stdout.write("\n");
settle();
};
const onData = (chunk: string): void => {
for (const character of chunk) {
if (character === "\r" || character === "\n" || character === "\u0004") {
finish(() => resolve(value));
return;
}
if (character === "\u0003") {
finish(() => reject(new InitialOwnerCliError("Cancelled. No changes were made.")));
return;
}
// Backspace and delete, so a mistyped character can be corrected in a field nobody can see.
if (character === "\u007f" || character === "\b") {
value = value.slice(0, -1);
continue;
}
value += character;
}
};
input.on("data", onData);
});
}
/** Reads standard input to end, for automated deployment. */
function readStdin(): Promise<string> {
return new Promise<string>((resolve, reject) => {
let buffer = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk: string) => {
buffer += chunk;
});
process.stdin.on("end", () => resolve(buffer));
process.stdin.on("error", () =>
reject(new InitialOwnerCliError("Standard input could not be read.")),
);
});
}
/**
* Obtains the password without it ever appearing in `argv`, an environment variable, or the
* terminal.
*
* A command-line password is visible in shell history and to every other process on the machine via
* the process list, and an environment variable tends to become permanent — a bootstrap password
* belongs to one invocation, not to the deployment's configuration, so this command deliberately
* reads no environment variable for it and none is added to `.env.example`.
*
* Interactive use asks twice and compares, because the field is invisible and a typo would create
* an owner nobody can sign in as, on an installation whose one bootstrap has now been consumed.
*/
async function readPassword(): Promise<string> {
if (process.stdin.isTTY === true) {
const password = await promptHidden("Password: ");
const confirmation = await promptHidden("Confirm password: ");
if (password !== confirmation) {
throw new InitialOwnerCliError("The passwords did not match. No changes were made.");
}
return password;
}
// A single trailing newline is stripped so `printf '%s\n'`, a here-string, and an editor-produced
// file all behave the same; nothing else is trimmed, because leading and trailing spaces are
// legitimate password characters.
const piped = await readStdin();
return piped.replace(/\r?\n$/u, "");
}
async function main(): Promise<void> {
const { email, name } = parseArgs(process.argv.slice(2));
// Read only when this command actually executes — never merely by importing this module.
const databaseUrl = process.env.DATABASE_URL;
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
throw new InitialOwnerCliError("DATABASE_URL is required.");
}
const password = await readPassword();
if (password.length === 0) {
throw new InitialOwnerCliError("A password is required. No changes were made.");
}
// Asserted before any connection is constructed, so an unsafe production transport never opens a
// client at all — identical to every other Core executable.
const transport = resolveExecutableDatabaseTransport(databaseUrl);
const databaseOptions = {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 30_000,
transport,
};
const authorization = createAuthorizationDrizzleDatabase(databaseUrl, databaseOptions);
const identity = createIdentityDrizzleDatabase(databaseUrl, databaseOptions);
try {
const result = await createInitialPlatformOwner(
{
authorizationDb: authorization.db,
createIdentity: createBootstrapIdentityCreator(identity.db),
identityDb: identity.db,
},
{ email, name: name ?? email, password },
);
// Never the password, its length, a hash, a session token, a user id, or the database URL.
console.log(
result.identity === "created"
? "Initial platform owner created successfully."
: "An account already existed for that address; platform ownership was granted to it and its password was left unchanged.",
);
console.log(`Email: ${result.email}`);
console.log("You can now sign in to the Ekklesia dashboard.");
} finally {
await Promise.all([
authorization.close().catch(() => undefined),
identity.close().catch(() => undefined),
]);
}
}
function reportInitialOwnerCliError(error: unknown): void {
if (error instanceof InitialOwnerCliError) {
console.error(error.message);
return;
}
if (error instanceof InitialPlatformOwnerError) {
console.error(`[${error.code}] ${error.message}`);
return;
}
// Deliberately generic: the caught value may carry SQL, a connection string, a driver detail, or
// the password itself if it were ever interpolated into a message upstream.
console.error("Initial platform owner creation failed unexpectedly.");
}
function isDirectExecution(): boolean {
const entrypoint = process.argv[1];
return (
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
);
}
if (isDirectExecution()) {
main().catch((error: unknown) => {
reportInitialOwnerCliError(error);
process.exitCode = 1;
});
}

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