151 Commits
Author SHA1 Message Date
The_River c5a6cdaf6a Merge pull request 'chore(release): prepare 0.2.2' (#87) from chore/release-0.2.2 into main
CI / verify (push) Successful in 1m7s
CI / infrastructure-smoke (push) Successful in 37s
CI / migration-smoke (push) Successful in 1m54s
CI / package-release (push) Successful in 35s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m22s
CI / package-publication (push) Successful in 1m0s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/87
Reviewed-by: The_River <[email protected]>
2026-10-09 22:07:46 +00:00
ekklesia c4fded6ecf chore(release): prepare 0.2.2
CI / verify (pull_request) Successful in 1m6s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
CI / package-publication (pull_request) Successful in 53s
2026-10-09 16:55:18 -05:00
The_River 9ad2d5b564 Merge pull request 'feat: corresponding-source artifacts for native packages that redistribute copyleft code (ADR 0055)' (#86) from feat/corresponding-source into main
CI / verify (push) Successful in 1m6s
CI / infrastructure-smoke (push) Successful in 37s
CI / migration-smoke (push) Successful in 1m58s
CI / package-release (push) Successful in 38s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 50s
CI / deploy-smoke (split) (push) Successful in 1m22s
CI / package-publication (push) Successful in 51s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/86
Reviewed-by: The_River <[email protected]>
2026-10-09 04:34:50 +00:00
ekklesiaandClaude Opus 5.5 98d93cc12d docs: accept ADR 0052, whose supplement mechanism is fully implemented
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 30s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / package-release (pull_request) Successful in 1m39s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
CI / package-publication (pull_request) Successful in 50s
Every decision in ADR 0052 is implemented and tested in @ekklesia/module-pack:
- package material first, and a supplement only adds to it;
- reviewed, generic supplement data, checked in with the packager;
- an exact name, version, and content-digest match, failing closed on any mismatch;
- the documented, host-independent digest;
- a deterministic, labelled merge;
- no licence override, with a consistency check;
- no network during packaging;
- packageFormat 1.

The libvips 1.3.3 and 1.3.4 supplements are curated and shipped. The source-obligation determination
it deferred is now decided by ADR 0055. Only the status line and the index entry change; the decision
text is untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 23:24:16 -05:00
ekklesiaandClaude Opus 5.5 e083c44e05 feat: corresponding-source artifacts for native packages that redistribute copyleft code (ADR 0055)
CI / verify (pull_request) Successful in 1m3s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / package-release (pull_request) Successful in 36s
CI / package-publication (pull_request) Canceled after 0s
CI / deploy-smoke (same-origin) (pull_request) Canceled after 0s
CI / deploy-smoke (split) (pull_request) Canceled after 0s
CI / image-build (pull_request) Canceled after 30s
Ekklesia may publicly distribute a native Module Package containing LGPL or MPL components only beside
a version-specific corresponding-source artifact. The artifact carries the exact source, patches,
build definitions, and rebuild and relink instructions for those components. ADR 0055 (Accepted)
records the policy: project compliance policy, not legal certification. It decides the item ADR 0052
deferred, and extends ADR 0049's distributable-release gate. Module Package v1, packageFormat 1, and
both libvips supplements are unchanged.

The mechanism is generic and lives in @ekklesia/module-pack:
- Source recipes: reviewed data under corresponding-sources/<package>/<version>/. recipe.json names
  the exact package (name, version, content digest) and the exact supplement it was prepared with,
  and lists every component with its role, licence, licence material, and relationship to the
  binary. Every source file and patch is recorded by origin, size, and SHA-256; documents are hashed
  too, and every exclusion has a reason. Loading is strict and refuses anything unlisted.
- Acquisition (the only network step) fetches each recorded file once into a cache keyed by
  SHA-256. It uses node:https with exact headers and no decoding, retries transient failures, and
  stores only bytes that match.
- Construction is offline. From a built package it finds the covered native dependencies (by
  declared licence, or by a supplement component's licence), verifies every byte, and writes four
  sibling artifacts: the deterministic <package>-corresponding-source.tar.gz and its .sha256;
  <package>.redistribution.json, linking package, dependencies, supplement, recipe, and source by
  digest; and <package>.source-notice.txt, the release notice.
- The public-distribution gate (verifyRedistribution, `ekklesia-corresponding-source verify`)
  approves only when the recipe, supplement, canonical archive, manifest, and metadata identify
  exactly these files. It refuses bundled copyleft code, and approves packages with no covered
  native code without an artifact. packModule is untouched.

First complete proof: @img/[email protected] (sharp 0.35.5), from the reviewed supplement
and provenance.
- 383 components and 387 source files, about 208 MB: the sharp-libvips v1.3.4 build definitions,
  the 27 library archives, the five build-time patches, the 353 crates librsvg's resolved Cargo.lock
  records, and rust-src for nightly-2026-09-27 (rustc 75a75c3e0), which vendors std's dependencies.
- BUILDING.md and two helpers point sharp-libvips's unchanged build logic at those files.
- Followed in a Rocky Linux 8 container with networking disabled, they rebuilt
  libvips-cpp.so.8.18.7, byte-identically across two builds. Swapped into a verified, extracted
  native package, the rebuilt library was the one loaded, and sharp ran.
- The native proof's source artifact, native-proof-0.1.0-linux-x64-glibc-corresponding-source.tar.gz,
  is 207242785 bytes, sha256 cc405492032b04a5391ab84e64f0e5e50f78e571ef7818204782c25a03eb50b7. It is
  byte-identical from unrelated directories and from a cache acquired fresh from upstream.

Tests:
- corresponding-source.test.ts runs in CI: synthetic inputs, every negative case, acquisition with
  retries and refusal, and determinism.
- corresponding-source-native.test.ts checks the real recipe everywhere. With a verified cache
  (`pnpm test:corresponding-source`, which acquires once) it builds the real artifact twice, gates
  it, fails it closed, and runs sharp from the package.

Docs: the module guide's distribution section, the README and architecture notes, and an ADR 0052
cross-reference.

No Signage change, version bump, publication, or tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 23:17:35 -05:00
The_River 4919db6168 Merge pull request 'chore(release): prepare 0.2.1' (#85) from chore/release-0.2.1 into main
CI / verify (push) Successful in 1m8s
CI / infrastructure-smoke (push) Successful in 37s
CI / migration-smoke (push) Successful in 1m54s
CI / package-release (push) Successful in 37s
CI / image-build (push) Successful in 13s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m21s
CI / package-publication (push) Successful in 52s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/85
Reviewed-by: The_River <[email protected]>
2026-10-09 03:00:59 +00:00
ekklesiaandClaude Opus 5.5 6e76293620 chore(release): prepare 0.2.1
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 38s
CI / migration-smoke (pull_request) Successful in 1m57s
CI / package-release (pull_request) Successful in 36s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 55s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 52s
Every platform workspace package and every public @ekklesia/* package declares 0.2.1, and the deploy
README's image build example names it. templates/module and the fixture modules keep their own
versions.

0.2.1, a patch: since v0.2.0 (1788d17), main has one change, #84. It adds the reviewed supplement for
@img/[email protected] and moves the packager's native proof to sharp 0.35.5. That is new
packager data and test-only dependencies. No SDK, manifest, database ABI, packageFormat, or package
API changed.

The standalone fixture consumes exactly this release: its @ekklesia ranges become ^0.2.1 and its
module-pack pin 0.2.1 (docs/development/releases.md, step 1). Module compatibility ranges stay ^0.2.0,
as the release guide says a patch version requires. The template, the standalone fixture, the native
proof module, and the test fixtures modelling the current platform are therefore unchanged. A module
built for 0.2.0 remains compatible with 0.2.1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 21:45:13 -05:00
The_River 3b54a7f023 Merge pull request 'feat: reviewed libvips 1.3.4 supplement, and the native proof on sharp 0.35.5' (#84) from feat/libvips-1.3.4-supplement into main
CI / verify (push) Successful in 1m7s
CI / infrastructure-smoke (push) Successful in 39s
CI / migration-smoke (push) Successful in 1m58s
CI / package-release (push) Successful in 42s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 52s
CI / deploy-smoke (split) (push) Successful in 1m26s
CI / package-publication (push) Successful in 52s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/84
Reviewed-by: The_River <[email protected]>
2026-10-09 02:43:21 +00:00
ekklesiaandClaude Opus 5.5 afaf3a853c feat: reviewed libvips 1.3.4 supplement, and the native proof on sharp 0.35.5
CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / package-release (pull_request) Successful in 34s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 47s
CI / deploy-smoke (split) (pull_request) Successful in 1m28s
CI / package-publication (pull_request) Successful in 50s
sharp 0.35.4 carries a production-relevant librsvg advisory (GHSA-wq5f-xc86-pv6w). Its fix, sharp
0.35.5, loads @img/sharp-libvips-linux-x64 1.3.4, which ships no licence material either. This adds
the reviewed supplement for that exact package (ADR 0052) and moves the packager's native proof to
sharp 0.35.5, so a later 0.2.1 release can package it on linux-x64-glibc. Nothing in module-pack's
code changes: supplement selection is already by exact name, version, and content digest.

- Supplement: license-supplements/@img/sharp-libvips-linux-x64/1.3.4/
  - content digest ekklesia-content-v1:sha256:2927fe11493461fa34847191d0b9b1c6441007c3e99f306e5708cb07fb0a5e59;
  - 190 components and 146 hashed texts;
  - assembled with the same method as 1.3.3's, from sharp-libvips v1.3.4's own inputs. That method
    reproduces the 1.3.3 supplement byte for byte.
  - PROVENANCE.md records:
    - the 27 upstream archives and five build-time patches, by sha256;
    - the librsvg 2.63.2 crate reproduction (cargo 1.99.0; Cargo.lock 5723e14c…);
    - rustc 75a75c3e…;
    - every change from 1.3.3.
  - Notable: sharp-libvips v1.3.4 now states cairo is used under MPL 1.1, not MPL 2.0. cairo ships
    that text itself, so the standard MPL 2.0 text is no longer added for cairo; selectors still
    carries it.
  - No legal conclusion: the LGPL and MPL source-availability determination stays open, as for 1.3.3.
- The 1.3.3 supplement is unchanged, and still proven: module-pack keeps sharp 0.35.4 as a test-only
  devDependency alias (`sharp-0.35.4`). Each supplement is pinned against its real installed
  libvips, and each matches only its own.
- Native proof fixture: sharp 0.35.4 → 0.35.5. The closure has the same shape: sharp, @img/colour,
  detect-libc, semver, and @img/sharp-linux-x64 0.35.5 with @img/sharp-libvips-linux-x64 1.3.4.
  The runtime read from the binaries is unchanged: Node-API 9, glibc >= 2.28, x86-64.
- Docs: the module guide's note on available supplements.

No version bump, no publication, no tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 21:34:35 -05:00
The_River 1788d17f0a Merge pull request 'fix: hand npm the release artifact's absolute path, so publishing finds it' (#83) from fix/publish-absolute-artifact-path into main
CI / verify (push) Successful in 1m6s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m56s
CI / package-release (push) Successful in 34s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m22s
CI / package-publication (push) Successful in 47s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/83
Reviewed-by: The_River <[email protected]>
2026-10-08 23:22:00 +00:00
ekklesiaandClaude Opus 5.5 1caffc5f26 fix: hand npm the release artifact's absolute path, so publishing finds it
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Successful in 34s
CI / image-build (pull_request) Successful in 24s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 49s
The first real 0.2.0 publish failed on its first package with ENOENT:
"Publishing ekklesia-contracts-0.2.0.tgz failed: npm error code ENOENT".

The publish workflow passes a relative release path,
--release "$(find package-train …)", which is
package-train/ekklesia-packages-0.2.0. The tool validated the artifacts
relative to the job's working directory, but ran npm in its own
temporary home (cwd /tmp/ekklesia-publish-XXXXXX) with the same relative
path, package-train/ekklesia-packages-0.2.0/ekklesia-contracts-0.2.0.tgz,
which there names nothing. The disposable-registry suite always passed
an absolute path, so it never saw this.

- loadReleaseTrain resolves the release directory once; every artifact
  path is absolute from validation on, and npm is given only that exact
  file's absolute path (anything else is refused at the npm boundary).
- Just before each upload the file is checked against the validated
  bytes once more; the tool logs the absolute path it publishes, and a
  failure names it with npm's path/syscall lines (npm's temporary home
  masked, the token never shown).
- Regression tests: unit tests with a recording npm (relative nested
  release from another cwd; npm's argv, cwd, and the file's existence; a
  changed artifact refused before npm runs), which fail on the previous
  tool; and a disposable-Gitea test that runs the CLI exactly as the
  workflow does, from the Actions artifact layout, round-tripping all
  nine packages byte for byte.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:51:22 -05:00
The_River debb8df3ce Merge pull request 'chore(release): prepare 0.2.0' (#82) from chore/release-0.2.0 into main
CI / verify (push) Successful in 1m6s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m55s
CI / package-release (push) Successful in 35s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m22s
CI / package-publication (push) Successful in 41s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/82
Reviewed-by: The_River <[email protected]>
2026-10-08 22:36:19 +00:00
ekklesiaandClaude Opus 5.5 98e59d4ba0 chore(release): prepare 0.2.0
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 44s
Every platform workspace package and every public @ekklesia/* package
declares 0.2.0, and the deploy README's image build example names it.
templates/module keeps its own version.

0.2.0, not 0.1.3: before 1.0 a minor version is the breaking one
(docs/development/releases.md, ADR 0054), and since 0.1.2 main has
broken modules, module authors, and operators:
- #64 (ADR 0048): one generic image; installed modules live outside it;
- #66, #72 (ADR 0049, 0050): modules install as Module Package v1, and
  move to the Module Database ABI;
- #71: the manifest v8 cutover; v7 manifests are refused;
- #79 (ADR 0053): the curated SDK drops defineModuleSchema and the
  legacy ModuleDatabase type;
plus #67 (AGPL), #74, #77 (licensing, native targets), and the public
package train (#80, #81). It is also the first package train to publish:
0.1.2 never will be.

Because 0.2.0 is outside ^0.1.0, the same change moves to ^0.2.0:
- the standalone fixture's @ekklesia ranges and its exact module-pack pin;
- the compatibility ranges of the modules built for this release: the
  template, the standalone fixture, and the native proof module;
- the test fixtures that model a module of the current release, or the
  current platform's versions;
- the module-development guide's examples, and the release guide, which
  now names what a minor bump moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:26:13 -05:00
The_River 22404ea2d4 Merge pull request 'feat: publish the exact @ekklesia package train to the npm registry (ADR 0053)' (#81) from feat/package-train-publication into main
CI / verify (push) Successful in 1m1s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m53s
CI / package-release (push) Successful in 32s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 42s
CI / deploy-smoke (split) (push) Successful in 1m22s
CI / package-publication (push) Successful in 42s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/81
Reviewed-by: The_River <[email protected]>
2026-10-08 16:31:57 +00:00
ekklesiaandClaude Opus 5.5 486356a01e docs: ADR 0054, Ekklesia's module-development packages are publicly readable before launch
CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m46s
CI / package-release (pull_request) Successful in 33s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 48s
CI / deploy-smoke (split) (pull_request) Successful in 1m27s
CI / package-publication (pull_request) Successful in 43s
ADR 0053 was accepted assuming a private package phase before launch. The
project owner has decided otherwise: Ekklesia is not secret, its source is
already public, and only publication must be restricted.

ADR 0054 supersedes ADR 0053 only where it describes that phase as
private or requires authenticated package reads. Packages may be read
anonymously, which is neither a launch nor an API stability promise
beyond pre-1.0 SemVer; publication stays authenticated and restricted;
published versions stay immutable; the River_of_Glory_Church Gitea
namespace is an acceptable host; a public launch remains a separate
decision. Every other ADR 0053 decision is unchanged.

ADR 0053 gets only a cross-reference line. The ADR index, the release and
module-development guides, the README status, and the publish workflow's
comment now point at ADR 0054.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 10:24:17 -05:00
ekklesiaandClaude Opus 5.5 1f30c86d4f fix: hand the proven release train on from a directory the artifact actions upload
CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 38s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / package-release (pull_request) Successful in 32s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m39s
CI / package-publication (pull_request) Successful in 59s
The CI package-release gate passed, but the upload found no files: the
artifact actions leave hidden files out, and .package-release starts
with a dot. Keep the release in package-train instead, in both
workflows, and test that no artifact path is hidden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 09:56:06 -05:00
ekklesiaandClaude Opus 5.5 d3c62e63bf feat: publish the exact @ekklesia package train to the npm registry (ADR 0053)
CI / verify (pull_request) Successful in 1m1s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / package-release (pull_request) Failing after 43s
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (split) (pull_request) Canceled after 0s
CI / deploy-smoke (same-origin) (pull_request) Canceled after 38s
Publishes one package release train to the Gitea npm registry under
River_of_Glory_Church, whose packages are publicly readable on purpose:
exactly the .tgz files the package-release gate proved, and nothing else.
Nothing is published by this change.

- scripts/publish-package-train.ts validates the whole local train before
  any request (gate manifest hash, version, revision, unmodified source,
  the nine packages, every artifact's size, SHA-256, integrity,
  SHA256SUMS line, and inner package.json), then publishes in dependency
  order with `npm publish <file.tgz>` under a staging tag. Each package
  is reconciled first (absent: publish; same bytes: accept; other bytes:
  hard stop, version burned), then read back: metadata (name, version,
  integrity, dependency maps, normalized bin, tarball on the registry)
  and the tarball itself, byte for byte, also anonymously. `latest` moves
  only after all nine verify; reruns converge and never delete anything.
  It writes a publication record. The token comes from
  EKKLESIA_PACKAGES_PUBLISH_TOKEN only, through a temporary npm config
  that refers to the variable.
- scripts/test-package-publication.sh proves it against a disposable
  Gitea 1.27.2 with public owners, a publisher team and an outsider:
  anonymous reads, refused unauthorized publishing, byte-identical round
  trips, the registry's own 409, partial-train recovery, the burned
  version hard stop, and dist-tag retry convergence.
- CI: package-release keeps its proven release and hands it on as an
  artifact with its manifest hash; the new package-publication job
  publishes exactly that artifact to the disposable Gitea. Artifact
  actions v3 (node20): v4 refuses any server other than github.com.
- .github/workflows/publish-packages.yml publishes to the real registry
  only by manual dispatch on main, after its own gate run, with the
  secret in the publish step alone.
- docs: the public-read model, the workflow, the record, credentials,
  immutability policy, provider moves, and the first-publication steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 09:49:27 -05:00
The_River 57df2e15f2 Merge pull request 'feat: the public @ekklesia package release train, built, reproduced, and proven (ADR 0053)' (#80) from feat/public-package-release-train into main
CI / verify (push) Successful in 1m5s
CI / infrastructure-smoke (push) Successful in 37s
CI / migration-smoke (push) Successful in 1m54s
CI / package-release (push) Successful in 32s
CI / image-build (push) Successful in 10s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m22s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/80
Reviewed-by: The_River <[email protected]>
2026-10-08 13:53:41 +00:00
ekklesiaandClaude Opus 5.5 4f1b1d68c7 fix: keep pnpm's package store out of the release builder's clean-source check
CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / package-release (pull_request) Successful in 32s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s
The package-release job failed its clean-tree assertion in CI. Reproduced
as root in a container with a bind-mounted checkout, as the Gitea runner
does: after checkout the tree is clean; `pnpm install --frozen-lockfile`
then creates `.pnpm-store/` (17,272 untracked files) at the checkout's
root, because pnpm keeps its store on the node_modules file system and
the checkout is its own mount; `pnpm build` changes nothing. No tracked
file is ever modified.

- `.pnpm-store/` is ignored, like node_modules: a package cache, never
  source. The clean-commit requirement is unchanged for every tracked
  file and every untracked file Git does not ignore.
- The clean-tree failure now lists `git status --short` and
  `git diff --stat`.
- Nothing is built before the clean-source assertion: the gate no longer
  runs a global `pnpm build` first. The builder asserts a clean tree,
  builds, then fails if its build changed the tree, before packing. Core's
  verifier is built after both releases, for the consumer proof only.
- Every secondary offline install (the second worktree, the consumer
  module) uses the workspace install's own store explicitly, since pnpm
  picks a store per file system and those directories are on another.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 08:43:19 -05:00
ekklesiaandClaude Opus 5.5 9debe4e891 feat: the public @ekklesia package release train, built, reproduced, and proven (ADR 0053)
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Failing after 16s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
Produces the exact public package tarballs of a platform release, validates
them, and proves a standalone module consumes those artifacts end to end.
Nothing is published and no credential is configured.

- scripts/package-release.ts (pnpm packages:release) is the canonical
  release builder: from a clean commit it rebuilds the nine public
  packages from clean output, packs each, inspects every tarball, checks
  the release set and coordinated versions, and only then writes
  ekklesia-packages-<version>/ with the tarballs, SHA256SUMS, and a
  provider-neutral manifest (ekklesia-package-train.json: version,
  revision, and each package's file, size, SHA-256, npm integrity).
- Packing uses a pack-only pnpm beforePacking hook (passed with
  --config.pnpmfile, never the workspace's install settings). pnpm inserts
  rewritten workspace: dependencies in async completion order, so two
  checkouts of one commit packed module-pack's package.json with keys in a
  different order; the hook restores the source key order (and the
  inspection enforces it). It also drops devDependencies, which named
  private packages outside the train.
- scripts/test-package-release.sh (pnpm test:package-release, the CI
  package-release gate) builds the train twice, the second time from a
  separate worktree, requires byte-identical release directories, then
  runs the standalone consumer proof on the release: manifest checked,
  offline install from the tarballs only, lockfile integrity equal to the
  manifest's, frozen-lockfile reinstall, no Drizzle, one React, contracts
  only transitive, every @ekklesia package and module-pack at the release
  version, skipLibCheck: false, UI build, pack, and Core verification.
- docs/development/releases.md documents the release order, the train,
  the artifacts, and the reproducibility findings; registry publication
  remains unimplemented.
- ADR 0053 is Accepted: everything it decides that does not need a
  registry is now proven, and registry provisioning is on its own
  Deferred list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 08:02:41 -05:00
The_River 7ea2d7883f Merge pull request 'feat: standalone module-build readiness for the public @ekklesia packages (ADR 0053)' (#79) from feat/standalone-module-build into main
CI / verify (push) Successful in 1m5s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m54s
CI / standalone-module (push) Successful in 27s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 43s
CI / deploy-smoke (split) (push) Successful in 1m21s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/79
Reviewed-by: The_River <[email protected]>
2026-10-08 12:48:07 +00:00
ekklesiaandClaude Opus 5.5 aaa7ebfd84 fix: keep Drizzle out of every published declaration (ADR 0053)
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 28s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
@ekklesia/contracts still emitted a type-only import of
drizzle-orm/node-postgres for the legacy ModuleDatabase facade, so a
standalone module type-checked only because skipLibCheck hid it.

- ModuleDatabase, LegacyModuleRuntimeServices and
  AnyModuleRuntimeServices move to core/src/modules/database.ts, beside
  the facade Core builds; the facade itself is unchanged.
- contracts' runtime, route, and services generics no longer name an
  ORM: the database type is a plain parameter defaulting to the ABI v1
  executor, and the legacy definition's database is opaque until Core
  specializes it. Core and apps/api name the legacy types from Core.
- contracts drops its drizzle-orm optional peer and devDependency;
  Drizzle stays public only through @ekklesia/module-database/drizzle.
- The facade's type-level test moves from contracts to Core.
- Regression proof: the standalone fixture type-checks with
  skipLibCheck: false against the packed tarballs, with no drizzle-orm
  installed (the smoke asserts both). Against the previous contracts
  tarball the same check fails with TS2307 on drizzle-orm/node-postgres.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 07:40:43 -05:00
ekklesiaandClaude Opus 5.5 89d5d0f2da feat: standalone module-build readiness for the public @ekklesia packages (ADR 0053)
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 28s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m42s
Makes the public module-development package set usable from a module
repository outside the monorepo. Nothing is published.

- Curated @ekklesia/module-sdk: an explicit author API, no contracts
  barrel, no identity/authorization internals, no Drizzle and no legacy
  ModuleDatabase type. defineModuleRuntime returns the SDK's own named
  interfaces so a module's emitted declarations never reach into
  @ekklesia/contracts. defineModuleSchema is gone; createModuleTable and
  defineModuleSchemaMetadata live in @ekklesia/module-database.
- Drizzle: no longer a dependency of module-sdk or contracts. Core keeps
  the legacy Drizzle schema validator (moved to core/src/modules) and
  its legacy runtime support; drizzle-orm is an optional peer only.
- React is a peer (plus devDependency) of @ekklesia/ui; the boundary
  checker allows exactly that consumer-selected peer.
- @ekklesia/typescript-config is publishable; module-ui-host is private.
  Every public package carries "ekklesia.distribution":
  "module-development" and no monorepo-root prepack.
- module-pack reads the Node.js range from the module's own engines.node
  and recognizes Ekklesia packages by name plus that marker, so
  registry-installed packages are incorporated, SDK-compatibility
  checked, and never credited as third-party.
- scripts/pack-public-packages.ts (pnpm packages:pack) rebuilds, packs
  and inspects the nine public tarballs; scripts/test-standalone-module.sh
  (pnpm test:standalone-module, new CI job) installs them into a fixture
  module outside the repository and proves it typechecks, builds,
  packages, and verifies.
- Public-package boundary tests and module-development docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 07:28:54 -05:00
The_River 9426c83109 Merge pull request 'docs: propose ADR 0053, standalone module builds from Ekklesia packages over the npm registry protocol' (#78) from docs/adr-0053-module-package-distribution into main
CI / verify (push) Successful in 1m6s
CI / infrastructure-smoke (push) Successful in 28s
CI / migration-smoke (push) Successful in 1m54s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 43s
CI / deploy-smoke (split) (push) Successful in 1m23s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/78
Reviewed-by: The_River <[email protected]>
2026-10-08 02:10:19 +00:00
ekklesiaandClaude Opus 5.5 47e64c6f39 docs: propose ADR 0053, standalone module builds from Ekklesia packages over the npm registry protocol
CI / verify (pull_request) Successful in 1m9s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m56s
CI / image-build (pull_request) Successful in 24s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m38s
Defines the external module-development and package-distribution contract
that ADR 0049 deferred.

- Protocol, not provider: packages are distributed over the standard npm
  registry protocol. Gitea may host the private phase. Host-neutral
  lockfiles and an Ekklesia proxy are not required; a registry move may need
  one configuration change and a lockfile regeneration, while integrity hashes
  keep verifying content.
- Public set:
  - Author API: module-sdk, module-database, module-ui-sdk, design-tokens.
  - Tooling: module-ui-build, module-pack, typescript-config.
  - ui as a pre-1.0 component API.
  - contracts only as a transitive implementation dependency.
  - Core, apps, module-ui-host, and module-template stay unpublished.
- Curated SDK before first publication: no full contracts barrel, no forced
  Drizzle at the SDK root, and no legacy Drizzle database contract in the
  standalone surface. Drizzle support lives in module-database's adapter.
- Dependency rules: React/ReactDOM are peers of the public UI packages
  (bundling per ADR 0023 is unchanged); Drizzle is an optional peer only
  where the adapter needs it; modules put runtime packages in dependencies
  and tooling/config in devDependencies.
- Versioning: one coordinated 0.x release version equal to the platform
  version, with no mixed trains. Ranges go in source and exact artifacts in
  lockfiles; module-pack is pinned exactly; independent versions deferred.
- Standalone packaging: no monorepo checkout. The module declares its own
  Node range (engines.node). The packager recognizes Ekklesia packages by
  identity and metadata, not path. No monorepo-root commands at install time.
- Supply chain: immutable versions, frozen-lockfile integrity, and a
  pre-publication smoke that installs the packed tarballs into a clean
  standalone fixture and proves build, package, and verify. Signing deferred.
- Private phase: no committed secrets; credentials come from the
  environment; verify actual read access before the first publication.

Status Proposed. ADR 0047 (still proposed) must be revised against this. No
code, registry, credential, or Signage changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 20:46:32 -05:00
The_River 5803757153 Merge pull request 'feat: native linux-x64-glibc module packages and reviewed license supplements (ADR 0049, ADR 0052)' (#77) from feat/native-targets-license-supplements into main
CI / verify (push) Successful in 1m13s
CI / infrastructure-smoke (push) Successful in 37s
CI / migration-smoke (push) Successful in 1m58s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 42s
CI / deploy-smoke (split) (push) Successful in 1m23s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/77
Reviewed-by: The_River <[email protected]>
2026-10-08 01:29:37 +00:00
ekklesiaandClaude Opus 5.5 44d385cd7f feat: native linux-x64-glibc module packages and reviewed license supplements (ADR 0049, ADR 0052)
CI / verify (pull_request) Successful in 1m6s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
Implements ADR 0049's deferred native target and ADR 0052's reviewed
license supplements. The proof case is a real module using sharp.
packageFormat stays 1, and `any` is unchanged.

Packager (@ekklesia/module-pack):
- --target linux-x64-glibc. Native or platform-specific packages, found by
  the same generic rule that refuses them in `any`, stay outside the bundle.
- Each native package's production closure is walked through its own
  dependencies, installed optionalDependencies that match the target, and
  required peers. It is copied as real files flat under node_modules/<name>,
  with no links, dev dependencies, declarations, source maps, or other
  platforms' packages. Missing or foreign-platform required dependencies,
  version conflicts, links, foreign machines, and Node.js range conflicts
  fail closed.
- runtime.nodeApi, nodeAbi, and libcVersion are read from the binaries by a
  small ELF reader: the addon's node_api_module_get_api_version_v1 and the
  newest GLIBC_ symbol version needed. No shelling out. A native target with
  no addon is refused.
- ADR 0052 supplements: package-provided material stays primary. An exact
  match on name, version, and ekklesia-content-v1 digest (sorted normalized
  paths with each file's size and SHA-256; no metadata) adds labelled
  material after the package's own. Any mismatch, a declared-licence
  conflict, or a malformed or unreviewed supplement file fails. Missing
  material with no supplement still fails.

Supplement for @img/[email protected]:
- 184 components and 143 unique texts, assembled from the exact upstream
  sources sharp-libvips v1.3.3 builds linux-x64 from: 27 C/C++ libraries,
  glib's gvdb, librsvg's Rust crates (reproduced with cargo tree), the Rust
  standard library at the rustc commit recorded in the binary, plus the
  LGPL-3.0, GPL-3.0, and MPL-2.0 texts. Each source is recorded with its
  SHA-256 in PROVENANCE.md.
- It pins digest ekklesia-content-v1:sha256:50647b0b…, which is identical
  from the installed copy and from the registry tarball.
- LGPL/MPL source-availability determinations stay open, as ADR 0052
  states. Nothing claims they are met.

Verifier (Core):
- linux-x64-glibc is accepted only on a host whose platform, arch, glibc
  family and version, and Node-API version (from Node itself) match.
- Every ELF file must be x86-64; every .node must be an ELF object.
- `any` packages now also refuse node_modules/ and ELF files.
- Contract: native metadata must state nodeApi or nodeAbi, and Linux targets
  libcVersion (ADR 0049).

Proof:
- A checked-in AGPL native fixture packs byte-identically from unrelated
  copies (8.86 MB) and verifies with Core's verifier.
- Extracted elsewhere, it runs a sharp resize and PNG round trip in an
  empty-environment process. Every resolution stays in the package, and
  only the package's addon and libvips plus host system libraries load.

Signage is untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 20:16:53 -05:00
The_River f53506b3f0 Merge pull request 'docs: propose ADR 0052, reviewed license supplements for redistributed dependencies' (#76) from docs/adr-0052-license-supplements into main
CI / verify (push) Successful in 1m5s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m53s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 41s
CI / deploy-smoke (split) (push) Successful in 1m23s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/76
Reviewed-by: The_River <[email protected]>
2026-10-08 00:47:12 +00:00
ekklesiaandClaude Opus 5.5 951453c735 docs: ADR 0052, what supplement review is, and what the match digest covers
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
Two wording clarifications; no decision changes.

- Repository review of a supplement is project and code review of a build
  input. It is not legal review, and not proof that redistribution
  obligations are satisfied.
- The exact-match digest is deterministic over normalized redistributed
  relative paths and file bytes. It excludes host filesystem metadata,
  timestamps, ownership, permissions, and extraction or listing order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 19:39:52 -05:00
ekklesiaandClaude Opus 5.5 99f145a80c docs: propose ADR 0052, reviewed license supplements for redistributed dependencies
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / image-build (pull_request) Canceled after 0s
CI / deploy-smoke (same-origin) (pull_request) Canceled after 0s
CI / deploy-smoke (split) (pull_request) Canceled after 0s
CI / migration-smoke (pull_request) Canceled after 1m10s
When a redistributed dependency's own package omits licence material that
redistribution requires, the canonical packager may use a checked-in,
reviewed supplement. The motivating case is
@img/[email protected]: LGPL-3.0-or-later, and its shared
library contains about 29 libraries, but it ships no licence text or
notice for any of them.

ADR 0052 decides:
- package-provided material stays the preferred source and is never
  replaced;
- a supplement matches exact package name, version, and a packager-computed
  content integrity; any mismatch fails closed, with no fallback;
- supplements are immutable, reviewed build inputs with no network access
  during packaging;
- they may carry licence texts, copyright and attribution notices,
  provenance, corresponding-source information, and other accompanying
  material;
- merging is deterministic, and supplement material is labelled;
- a supplement never overrides a dependency's declared licence; a
  disagreement fails packaging;
- the mechanism is generic, with no package-specific code;
- missing material with no exact supplement still fails packaging;
- packageFormat stays 1.

It extends ADR 0049 only in the collection rule that ADR delegated to the
implementation. Legal and source-obligation determinations for LGPL, MPL,
and similar binaries are explicitly deferred: no written offer, URL, or
mirror is asserted to satisfy any licence.

Status Proposed. No implementation, no libvips supplement, and no Signage
change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 19:37:11 -05:00
The_River 104a5145c8 Merge pull request 'test: give Core's import-safety tests a timeout that holds under full verify load' (#75) from test/import-safety-timeouts into main
CI / verify (push) Successful in 1m3s
CI / infrastructure-smoke (push) Successful in 28s
CI / migration-smoke (push) Successful in 1m48s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / deploy-smoke (split) (push) Successful in 1m22s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/75
Reviewed-by: The_River <[email protected]>
2026-10-08 00:18:06 +00:00
ekklesiaandClaude Opus 5.5 796ad4f16e test: give Core's import-safety tests a timeout that holds under full verify load
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m55s
CI / image-build (pull_request) Successful in 24s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
Four Core tests prove that importing a module has no side effects. Each one
does a cold dynamic import() of a large Core source graph inside its test
body:
- doctor-import
- module-http-import
- cli-bootstrap-platform-owner
- cli-create-initial-owner (its first test)

Vitest transforms and evaluates that graph on demand, so the CPU-bound cost
counts against the default 5 s per-test timeout. Ordinary test files import
at the top level, outside any test timeout. There is no subprocess, shared
resource, or test bug.

Measured on six cores:
- alone: 0.7–1.2 s;
- alongside five other packages' suites, as in a full pnpm verify:
  3.0–5.4 s (doctor-import reached 5,400 ms).

A shared IMPORT_SAFETY_TEST_TIMEOUT_MS of 30 s (core/tests/support/
import-safety.ts) now applies to exactly these four tests. That is about five
times the slowest time measured under load, the same as the repository's
integration-test timeout, and still bounded, so an import that genuinely
hangs fails. Assertions, coverage, the global Vitest timeout, parallelism,
and production code are unchanged; there are no retries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 19:08:30 -05:00
The_River b1e3244c1d Merge pull request 'feat: Module Package v1 carries the module's own license (ADR 0051)' (#74) from feat/module-package-v1-licensing into main
CI / verify (push) Successful in 1m6s
CI / infrastructure-smoke (push) Successful in 38s
CI / migration-smoke (push) Successful in 2m7s
CI / image-build (push) Successful in 15s
CI / deploy-smoke (same-origin) (push) Successful in 56s
CI / deploy-smoke (split) (push) Successful in 1m23s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/74
Reviewed-by: The_River <[email protected]>
2026-10-08 00:03:01 +00:00
ekklesiaandClaude Opus 5.5 85fae9e07e feat: Module Package v1 carries the module's own license (ADR 0051)
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s
Every Module Package v1 now declares the module's license and carries its
text. packageFormat stays 1; pre-ADR-0051 metadata fails closed.

- Contracts: ekklesia.package.json requires an SPDX `license` expression,
  validated with spdx-expression-parse, with UNLICENSED and non-expressions
  refused. The `files` list must hold exactly one non-empty root LICENSE.
  The package contracts move to the @ekklesia/contracts/module-package
  subpath, so the SPDX parser and its data (spdx-exceptions and
  spdx-license-ids ship no licence files) never reach a module bundle
  through the SDK's re-export of the contracts root.
- Packager: reads the source package.json's string SPDX declaration and
  never invents one. It requires an explicit --license-file (a non-empty
  regular file, no symlink, no parent search, no download) and copies its
  bytes unchanged to LICENSE, listed with size and SHA-256 and covered by
  the archive digest. THIRD_PARTY_NOTICES stays third-party only, and
  bundled Ekklesia code stays in `incorporates`.
- Verifier: rejects missing or invalid license metadata, and a missing,
  unlisted, duplicated, or corrupted LICENSE, through the schema and the
  existing strict inventory.
- Template: packaged as AGPL-3.0-or-later with the repository's canonical
  LICENSE, passed explicitly (Dockerfile, smoke, docs). Packing it twice
  gives byte-identical archives.

Started by Codex; reviewed and completed in Claude Code (empty license-text
refusal, schema non-empty LICENSE, symlink and empty-file tests).

ADR 0051 is Accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 18:51:03 -05:00
The_River 30f61094e6 Merge pull request 'docs: propose ADR 0051 module package licensing' (#73) from docs/adr-0051-module-package-licensing into main
CI / verify (push) Successful in 1m6s
CI / infrastructure-smoke (push) Successful in 37s
CI / migration-smoke (push) Successful in 1m56s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 42s
CI / deploy-smoke (split) (push) Successful in 1m22s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/73
Reviewed-by: The_River <[email protected]>
2026-10-07 23:29:19 +00:00
ekklesia 1c7755d320 docs: propose ADR 0051 module package licensing
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
2026-10-07 18:21:51 -05:00
The_River 42acdeb9e8 Merge pull request 'feat: transition module packages and template to database ABI v1' (#72) from feat/module-package-abi-v1-transition into main
CI / verify (push) Successful in 59s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m53s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m21s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/72
Reviewed-by: The_River <[email protected]>
2026-10-07 23:14:49 +00:00
ekklesia 70854d234c feat: transition module packages to database ABI v1
CI / verify (pull_request) Successful in 1m1s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 52s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s
2026-10-07 18:00:04 -05:00
The_River 20ad1ae208 Merge pull request 'feat: deliver module database ABI through manifest v8' (#71) from feat/manifest-v8-database-abi into main
CI / verify (push) Successful in 1m4s
CI / infrastructure-smoke (push) Successful in 38s
CI / migration-smoke (push) Successful in 1m47s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m24s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/71
Reviewed-by: The_River <[email protected]>
2026-10-07 22:45:52 +00:00
ekklesia 49b4d4165d feat: deliver module database ABI by manifest
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
2026-10-07 17:29:32 -05:00
The_River a1cdcb3ad1 Merge pull request 'feat: add module database ABI v1 adapters' (#70) from feat/module-database-abi-adapters into main
CI / verify (push) Successful in 1m4s
CI / infrastructure-smoke (push) Successful in 37s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 43s
CI / deploy-smoke (split) (push) Successful in 1m23s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/70
Reviewed-by: The_River <[email protected]>
2026-10-07 22:00:06 +00:00
ekklesia 859675c3c7 feat: add module database ABI v1 adapters
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
2026-10-07 16:44:28 -05:00
The_River 4a3958c616 Merge pull request 'feat: Core's Module Database ABI v1 executor, proven against PostgreSQL (ADR 0050)' (#69) from feat/module-database-abi-executor into main
CI / verify (push) Successful in 56s
CI / infrastructure-smoke (push) Successful in 35s
CI / migration-smoke (push) Successful in 1m48s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 41s
CI / deploy-smoke (split) (push) Successful in 1m22s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/69
Reviewed-by: The_River <[email protected]>
2026-10-07 21:25:15 +00:00
ekklesiaandClaude Opus 5.5 317a4ce410 fix: ADR 0050 callback-error precedence, and the honest limits of transaction control
CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 38s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Successful in 1m37s
Narrow contract and implementation adjustments from review. No SDK, manifest,
packaging, or Signage work.

Callback-error precedence:
- A transaction or savepoint callback that rejects keeps its own error, even
  while sibling work it started is still settling (one failed half of a
  Promise.all). Core drains that work, rolls back, and never commits or
  releases.
- A callback that resolves with work outstanding (a forgotten await) still
  fails with MODULE_DATABASE_SCOPE_WORK_OUTSTANDING and rolls back.

Transaction-control SQL:
- ADR 0050 forbids it through query() as a conformance rule for trusted code,
  not a security guarantee. Core does not parse SQL, so PostgreSQL has run
  the statement before Core sees it. Core detects the violation from the
  command tag or the transaction status, and claims nothing it cannot do: a
  module's own COMMIT has committed.
- After a violation, the transaction's scopes refuse every later statement
  without sending it (it would otherwise autocommit). Core never commits the
  transaction itself, and the connection is destroyed rather than reused,
  for top-level violations too.
- The commit and rollback guarantees apply to conforming use.

Duplicate object-mode columns: documented as found only after execution, so a
mutation can have taken effect; atomicity needs an ABI transaction.

Tests prove each of these against PostgreSQL: Promise.all failures in a
transaction and a savepoint keep 23505; a forgotten await still rolls back; a
module COMMIT's prior work is committed while nothing after it runs; a backend
PID changes after every violation; duplicate RETURNING columns persist at the
top level and roll back in a transaction.

ADR 0050 stays Accepted: the executor conforms to the revised text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:12:01 -05:00
ekklesiaandClaude Opus 5.5 6db50516b7 feat: Core's Module Database ABI v1 executor, proven against PostgreSQL (ADR 0050)
CI / verify (pull_request) Successful in 59s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
Implements the first ADR 0050 slice: Core's ORM-neutral executor and its
PostgreSQL evidence. Nothing is wired to modules yet; manifest v8, SDK
adapters, schema metadata, packaging, and Signage are later slices. The
legacy ModuleDatabase facade is unchanged.

Contracts (@ekklesia/contracts, no imports at all):
- ABI version 1; query { text, values, rowMode } returning
  { command, rowCount, rows, fields: [{ name, typeOid }] };
- executor, transaction, and savepoint types with exactly the ABI's
  members; parameter and result value types;
- ModuleDatabaseError { code, sqlState? }: eight stable codes, and a
  SQLSTATE only when PostgreSQL supplied one; static messages, no cause,
  frozen; isModuleDatabaseError recognises it by shape.

Core (createModuleDatabaseExecutor over the API's existing pool):
- one statement per query, enforced by forcing the extended query
  protocol so PostgreSQL itself refuses a second statement (42601);
  no SQL parsing;
- parameters: null, boolean, number, bigint, string, Uint8Array, and
  rectangular arrays of them; everything else refused before sending;
- a Core-owned decoding table that never consults pg's global parser
  registry: int8, numeric, temporal, and JSON as exact text; bytea as a
  plain Uint8Array; built-in arrays element by element;
- every operation pins TimeZone, DateStyle, IntervalStyle, bytea_output,
  and extra_float_digits, then resets with DISCARD ALL before returning
  the connection, or destroys it when it cannot be reset cleanly;
- callback-scoped transactions with explicit isolation and access mode;
  a COMMIT that PostgreSQL answers with ROLLBACK is reported as failed;
- savepoints named by Core; only the innermost scope is active; scopes
  are revoked when their callback settles;
- a callback that settles with work outstanding is rolled back, never
  committed or released;
- transaction-control statements sent through query are refused by
  command tag and transaction status, and fail the transaction;
- the module ID is carried for Core's attribution only.

Tests:
- 83 integration tests on a database with hostile defaults: queries,
  the one-statement rule, parameters, decoding, errors, transactions,
  savepoints, forgotten awaits, pool safety after every failure class,
  and session state;
- unit tests for encoding, decoding, options, sanitization, and scope
  bookkeeping;
- type-level and source checks that no ORM or driver object crosses
  the boundary.

ADR 0050 is Accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:01:46 -05:00
The_River d43031997b Merge pull request 'docs: propose ADR 0050, Module Database ABI v1' (#68) from docs/adr-0050-module-database-abi into main
CI / verify (push) Successful in 59s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m57s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 50s
CI / deploy-smoke (split) (push) Successful in 1m22s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/68
Reviewed-by: The_River <[email protected]>
2026-10-07 20:33:49 +00:00
ekklesiaandClaude Opus 5.5 11e2495484 docs: ADR 0050, tighten transaction scope, single-statement, and error rules
CI / verify (pull_request) Successful in 56s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
Three narrow clarifications to the proposed contract. No other decision
changes.

- Outstanding work: module code awaits every operation before its
  transaction or savepoint callback returns. A callback that settles
  with work outstanding is a scope-usage error and never commits. Core
  may let started operations settle before rolling back and releasing
  the connection, but waiting never turns into a commit. Executors stay
  revoked once their scope has finished.
- One statement per query() call: a correctness and API rule, not a
  security boundary. It is upheld by a reliable driver or protocol
  mechanism, never a home-grown SQL parser. An implementation that
  cannot uphold it stops and reports instead of weakening the ADR.
- Errors: `code` is a stable Ekklesia-owned category and always present.
  `sqlState` is optional, present only when PostgreSQL supplied one.
  ABI-detected failures (closed scope, invalid parameter, invalid
  option) carry a code and no SQLSTATE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 15:27:29 -05:00
ekklesiaandClaude Opus 5.5 759872834d docs: propose ADR 0050, Module Database ABI v1
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m50s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
Core and modules exchange PostgreSQL SQL text, bound parameters, result
rows with field metadata, and Core-owned transaction scopes. Never ORM
objects. A module may use Drizzle, Kysely, raw SQL, or another
module-side builder. Core's own ORM becomes an implementation detail.

ADR 0050 decides:
- an explicit correctness/compatibility boundary, not a security
  sandbox: no claim for SQL parsing, execution-time prefix checks,
  SET ROLE, or PostgreSQL roles;
- opt-in through manifest version 8 `database.abi: 1`, a hard cutover
  like every manifest version; a version 8 module without `database`
  keeps the legacy ModuleDatabase facade during the deprecation window;
- the executor: query({ text, values, rowMode }) returning
  { command, rowCount, rows, fields }, one executor per module;
- a small parameter set (no Date, no implicit JSON, no identifiers);
- a Core-owned decoding table that loses no precision and does not
  depend on time zones (int8, numeric, temporal, and JSON as text;
  bytea as Uint8Array);
- a sanitized ModuleDatabaseError { code, sqlState? }, recognised
  structurally;
- callback-scoped transactions and Core-named savepoints, revoked when
  their callback settles;
- module-side adapters (Drizzle via pg-proxy, raw SQL, later Kysely);
- plain schema metadata for ABI v1 modules instead of Drizzle objects;
- migrations unchanged; packageFormat stays 1; ABI v1 packages omit
  transitional.databaseRuntime.

It partly supersedes ADR 0016 and amends ADR 0007 for ABI v1 modules,
and extends ADR 0049. Status Proposed. Accepted ADRs are unchanged. No
runtime, SDK, manifest, packaging, migration, or Signage changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 15:18:59 -05:00
The_River 9ddb5e2796 Merge pull request 'chore: license Ekklesia under AGPL-3.0-or-later' (#67) from chore/agpl-license into main
CI / verify (push) Successful in 56s
CI / infrastructure-smoke (push) Successful in 35s
CI / migration-smoke (push) Successful in 1m51s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 42s
CI / deploy-smoke (split) (push) Successful in 1m21s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/67
Reviewed-by: The_River <[email protected]>
2026-10-07 19:13:05 +00:00
ekklesiaandClaude Opus 5.5 52a665502f chore: license Ekklesia under AGPL-3.0-or-later instead of AGPL-3.0-only
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
The project decision is the GNU Affero General Public License, version 3 or
(at your option) any later version.

- "license": "AGPL-3.0-or-later" in all 14 Ekklesia-owned package.json files.
- README: the License section states AGPL v3 or any later version (SPDX
  AGPL-3.0-or-later) and carries the FSF's standard grant: "either version 3 of
  the License, or (at your option) any later version", with its warranty
  disclaimer and pointer to the license.
- Module template README and module development guide: AGPL-3.0-or-later for
  Ekklesia and official modules.

LICENSE is unchanged: it remains the canonical AGPLv3 text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 13:53:56 -05:00
ekklesiaandClaude Opus 5.5 9a328f647e chore: license Ekklesia under AGPL-3.0-only
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m39s
Ekklesia is open source under the GNU Affero General Public License, version 3
only. Module Package v1 (#66) bundles Ekklesia's SDK and contracts into
distributable module packages, so the platform needs an explicit license
before such artifacts are distributed.

- LICENSE: the GNU AGPL v3 text, verbatim from
  https://www.gnu.org/licenses/agpl-3.0.txt
  (sha256 0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0).
- "license": "AGPL-3.0-only" in the root package.json and every Ekklesia
  workspace package (13 packages), the module template included. Nothing else in
  any manifest changed.
- README: a License section replacing the "no license selected yet" placeholder:
  AGPL v3 only, source available under its terms, section 13's network-use
  obligation, and the policy that official Ekklesia modules use AGPL-3.0-only.
- Module template README and module development guide: the template declares
  the license; Module Package v1 does not yet record a module's own license,
  which needs a format change under ADR 0049.

AGPL-3.0-only, not -or-later: the decision is AGPL-3.0, and nothing in the
repository grants later versions. No runtime, ADR, package-format, Signage, or
instance change. The packaged template is byte-identical before and after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 13:38:33 -05:00
The_River 22caa1dceb Merge pull request 'feat: Ekklesia Module Package v1, the canonical packager and verifier (ADR 0049)' (#66) from feat/module-package-v1 into main
CI / verify (push) Successful in 57s
CI / infrastructure-smoke (push) Successful in 26s
CI / migration-smoke (push) Successful in 1m50s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 42s
CI / deploy-smoke (split) (push) Successful in 1m26s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/66
Reviewed-by: The_River <[email protected]>
2026-10-07 18:33:28 +00:00
ekklesiaandClaude Opus 5.5 27d85e0162 fix(smoke): hand the verifier the package's archive and digest as one readable release pair
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
The split deploy smoke failed in CI with "The SHA-256 file could not be read."
BuildKit's local exporter creates its output directory (`--output
type=local,dest=$work/module-package`) with mode 0700, owned by whoever ran the
build. That directory was mounted into the verifier, which runs as the image's
unprivileged `node` user (uid 1000). In CI the build runs as root, so the
directory was root:root 0700 and the verifier could read neither file; the
digest file is simply read first. On a workstation the build runs as uid 1000,
the same uid as `node`, so it was readable by coincidence.

scripts/lib/module-package-release.sh turns one build's output into the release
pair: it requires exactly one archive and its .sha256 and nothing else, checks
that the digest line names that archive, then creates a fresh directory that
must not exist yet (0755) and copies both files into it (0644). The smoke stages
and mounts only that directory; BuildKit's output directory is never mounted.
Archive-digest verification stays mandatory: the verifier still reads the
.sha256 file. Nothing in the verifier changed.

Tests: the helper is driven in bash against a 0700 build output, and refuses a
missing, malformed, or mismatched digest, stray output, a second archive, and
an existing release directory, creating nothing. A static test pins the smoke
to the release pair and the verifier's single mount with --sha256-file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 13:10:58 -05:00
ekklesiaandClaude Opus 5.5 35ef31df9f feat: Ekklesia Module Package v1, the canonical packager and verifier (ADR 0049)
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s
A separately versioned module is now distributed as one deterministic
.tar.gz and its SHA-256, produced by an Ekklesia-owned packager and checked by
Core before anything is installed. This slice implements the portable `any`
target, with the module template as the end-to-end proof. ADR 0049 is
accepted.

@ekklesia/contracts: the Module Package v1 contract. The strict
ekklesia.package.json schema (packageFormat 1, identity, target, runtime,
provenance, incorporated Ekklesia packages, strict transitional facts, an
exact byte-wise-sorted file list with size and SHA-256), target names, and the
generated runtime package.json.

@ekklesia/module-pack (new, tooling): `ekklesia-pack-module --module <dir>
--target any --out <dir> --source-revision <commit>`.
- Bundles server, schema, and jobs with esbuild, SDK, contracts, and Drizzle
  included; only Node built-ins remain imports. Any warning, unresolved
  import, external dependency, or native or platform-specific package fails.
- Copies browser artifacts as built, after proving them current, and the
  migrations; generates package.json and ekklesia.package.json; never ships
  src, tests, declarations, source maps, or workspace ranges.
- THIRD_PARTY_NOTICES: every third-party package traced from the bundles'
  own inputs, credited once with its declared licence and every licence,
  copying, copyright, and notice file it ships. A missing licence fails; a
  missing text fails unless its standard text is complete without copyright
  lines (Apache-2.0, vendored, needed by drizzle-orm).
- Deterministic ustar and gzip: sorted paths, uid/gid 0, empty names, mtime
  0, 0755/0644, no links. Byte-identical across runs and machines.
- Writes only the archive and its .sha256; never deploys, links, or installs,
  so the workspace's install state is untouched.

Core: verifyModulePackage and `verify-module-package.js <archive>
[--sha256 | --sha256-file] [--extract-to <new dir>]`. Reads the archive in
memory and refuses links, special entries, setuid/setgid/sticky modes, unsafe
or escaping paths, and a second top-level directory; checks metadata, every
file, identity, target, Node.js range, SDK, and the transitional Drizzle
agreement; runs the platform's module and migration discovery; loads the
backend once in a forked process with an empty environment; only then
extracts, with normalized modes, into a new directory. Errors are sanitized.

@ekklesia/module-ui-build: analyzeModuleUi reports each artifact's inputs
and whether the built file is current. The nested stylesheet build now works
from the module directory, so artifacts no longer depend on the caller's
working directory.

The split deploy smoke builds the package with the canonical packager
(scripts/lib/build-module-package.Dockerfile replaces the test-only pnpm
deploy packaging), verifies it with the same generic image, installs the
verified tree, removes it, and reinstalls the exact same archive.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 12:44:54 -05:00
The_River 45d6506c10 Merge pull request 'docs: propose ADR 0049, Ekklesia Module Package v1' (#65) from docs/adr-0049-module-package into main
CI / verify (push) Successful in 55s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m52s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 41s
CI / deploy-smoke (split) (push) Successful in 1m26s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/65
Reviewed-by: The_River <[email protected]>
2026-10-07 15:21:30 +00:00
ekklesiaandClaude Opus 5.5 1119cc5fdf docs: propose ADR 0049, Ekklesia Module Package v1
CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m50s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m41s
A separately versioned module is distributed as one immutable, self-contained,
deterministic .tar.gz with a separately distributed SHA-256. The package
contract is independent of hosting, registry, transport, and marketplace.

ADR 0049 extends ADR 0048 with:
- the package layout: one top-level module directory with a generated runtime
  package.json, the unchanged manifest, ekklesia.package.json, bundled backend
  entrypoints, built browser artifacts, migrations, THIRD_PARTY_NOTICES, and
  node_modules only for native dependencies;
- backend bundling of all pure-JavaScript dependencies, with externals decided
  and validated by the canonical packager, never by module authors;
- targets: `any`, or <platform>-<arch>[-<libc>] (linux-x64-glibc for Signage);
- ekklesia.package.json (packageFormat 1): id, version, target, runtime needs,
  provenance, incorporated SDK versions, a `transitional` Drizzle requirement
  explicitly not the Module Database ABI, and per-file size and SHA-256;
- archive safety: no links of any kind, no absolute or `..` paths, no special
  files or setuid/setgid; extraction normalizes ownership and modes;
- the intended installation lifecycle and its rollback limit: code-only
  rollback only before new migrations apply, a backup restore after;
- integrity, not authenticity; signing and trust roots deferred;
- pnpm deploy explicitly not the packaging mechanism.

Status Proposed, following the repository's usual lifecycle. ADR 0047 gains a
cross-reference to consume canonical packages. The module development guide no
longer recommends running pnpm deploy in a workspace, which corrupts that
workspace's install state. No runtime, tooling, Docker, CI, Signage, or
instance changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 10:13:34 -05:00
The_River fbf8f6edac Merge pull request 'feat: one generic image, with installed modules in installation-owned storage (ADR 0048)' (#64) from feat/installed-module-storage into main
CI / verify (push) Successful in 53s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m52s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 43s
CI / deploy-smoke (split) (push) Successful in 1m17s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/64
Reviewed-by: The_River <[email protected]>
2026-10-07 14:15:31 +00:00
ekklesiaandClaude Opus 5.5 f122e910fc test: generate the external module fixture instead of copying ignored build output
CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 28s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m37s
The test proving that migrations of a module installed outside the repository
are discovered, while Core's come from the platform, copied templates/module
including dist/. dist/ is gitignored and never committed. CI runs `pnpm test`
before any build of the template, where only the template's own UI test may
have written dist/ui, dist/display, and dist/contributions, so discovery
failed with MODULE_ENTRYPOINT_MISSING for ./dist/server/index.js and
./dist/schema/index.js (and, had the UI test not run yet, the copy itself
would have failed). Locally a previous `pnpm build` had left dist/ complete.

The test now generates a minimal valid installed module in a temporary
directory with createMigrationModule, moved unchanged from the migration
discovery test into core/tests/support so both share it. It depends on no
build output, ignored file, or node_modules. It also asserts more precisely:
the module directory is outside the repository, Core's migrations match the
platform's own core/drizzle inventory by identity and checksum, and the
module's migration matches the SQL that was installed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 09:07:59 -05:00
ekklesiaandClaude Opus 5.5 e36b70d8d4 feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
The production image now contains zero feature modules and is the same for every
installation. Installed modules live in module storage that one Core resolver
selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an
absolute path that must exist when set (/var/lib/ekklesia/modules in the image).
A relative, blank, missing, or unreadable configured directory fails closed with
a sanitized error that never contains the value.

- Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply
  and validate, module validation, module enablement, and Doctor all resolve it
  the same way; the test-only API override still takes precedence.
- Doctor receives Core's migrations directory explicitly instead of deriving it
  from the modules directory's parent.
- The migration runner retains and reports the applied history of a module that
  is not installed, and plans and applies Core's and present modules' migrations
  around it. Core's history and every installed module's history stay strict: a
  missing or changed applied migration, or a downgrade, still fails. No down
  migrations, no data deletion.
- deploy/Dockerfile copies no module and installs nothing for the module
  template; .dockerignore excludes modules/ and the build refuses a context that
  contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and
  sets EKKLESIA_MODULES_DIR.
- deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api
  and migrate, with create_host_path disabled.
- scripts/check-image.sh proves the image holds no modules and that a populated
  modules/ directory leaves its runtime file system identical; CI runs it.
- The split deploy smoke now uses the same generic image: it boots with no
  modules, installs a self-contained test module packaged from the template
  (test-only), migrates and restarts, removes it (Core runs, planning reports the
  retained history, data survives), and restores it with its data.

Docs: deploy README (module storage, install/update/remove/restore, the runtime
modules target), .env.example, API configuration, module development guide,
testing, workflow, security, and the architecture overview.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 08:38:14 -05:00
The_River 4d28a82937 Merge pull request 'docs: accept ADR 0048, one generic image with installed modules outside it' (#63) from docs/adr-0048-installed-modules into main
CI / verify (push) Successful in 50s
CI / infrastructure-smoke (push) Successful in 35s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (same-origin) (push) Successful in 47s
CI / deploy-smoke (split) (push) Successful in 44s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/63
Reviewed-by: The_River <[email protected]>
2026-10-07 13:04:24 +00:00
ekklesiaandClaude Opus 5.5 cefdde36a2 docs: accept ADR 0048, one generic image with installed modules outside it
CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 21s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m3s
Ekklesia produces one platform image with zero feature modules. Installed
modules live in installation-owned storage selected by EKKLESIA_MODULES_DIR
(/var/lib/ekklesia/modules in the image, <repo>/modules in development),
mounted read-only and discovered only at a controlled restart. An installed
module directory is self-contained; the platform lends it no dependencies.

Uninstall is distinct from disable and purge: removing module code keeps its
data and stored state, and applied migration history of an absent module is
retained and reported instead of blocking planning. Present modules and Core
stay strictly validated.

Supersedes in part ADR 0044 (composition from pinned sources) and ADR 0009
(rejection of history absent from the inventory); amends ADR 0006; extends
ADR 0028 and ADR 0030. ADR 0047 gains a cross-reference saying it must be
revised before acceptance, and deploy/README.md notes that its composition
model is being replaced. No code, Dockerfile, Compose, CI, or test changes.

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

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

templates/module keeps its own version.

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

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

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

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

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

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

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

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

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

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

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

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

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

The sweep ships in the harness image.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 18:25:12 -05:00
The_River b2efb4b029 Merge pull request 'feat: support split Dashboard and API origins (ADR 0045)' (#54) from feat/split-origins into main
CI / verify (push) Successful in 1m27s
CI / infrastructure-smoke (push) Successful in 22s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 7s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / deploy-smoke (split) (push) Successful in 45s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/54
Reviewed-by: Joey <[email protected]>
2026-10-03 19:53:52 +00:00
ekklesiaandClaude Opus 5.5 f63f80f2f0 feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 49s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m4s
An installation can now serve the Dashboard and the API on sibling
hostnames (dash./api.) beside the default same-origin topology, from one
image and with configuration alone.

- The Dashboard learns its API origin at run time: the built page carries
  one empty <meta name="ekklesia-api-origin">, which the API fills with its
  identity base URL's origin at startup, before computing the page's ETag.
  The shell accepts only a canonical http(s) origin and otherwise fails
  closed without making a request. The build-time EKKLESIA_API_BASE_URL is
  gone from the bundle; the development server injects the same element.
- The display host stays on the API's origin, so its device cookies,
  requests, and offline cache are unchanged.
- The API trusts its own origin (trusted origins plus the identity base
  URL), as Better Auth already does, so the display page's mutations pass
  the origin check without operators listing it.
- Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup
  does (it previously skipped it), reports the topology without printing
  hostnames, and warns when split hostnames are not siblings.
- The module UI contract states the CORS header limit (safelisted headers
  plus Content-Type, safelisted response headers only).
- Reference deployment: Caddyfile.split.example (Dashboard hostname serves
  / and its script; API hostname serves /api/*, /display/*, and the
  display script; each refuses the other's paths) and
  EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples.
- The deploy smoke test runs either topology (CI runs both). The split run
  installs the module template as a test-only module, checks per-hostname
  routing and exact CORS, then drives a digest-pinned headless Chromium
  over the DevTools protocol, with no added dependency: sign-in on the
  Dashboard hostname, the session cookie host-only on the API hostname,
  the module UI imported cross-origin and its credentialed request
  succeeding, the display and its service worker on the API hostname, and
  sign-out.

ADR 0045 is marked Accepted.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 13:29:11 -05:00
ekklesiaandClaude Opus 5.5 55accd0e65 docs: state exactly what ADR 0045's browser smoke proves
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (pull_request) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 13:29:11 -05:00
ekklesiaandClaude Opus 5.5 a56a853319 docs: propose ADR 0045, split Dashboard and API origins
Records how an installation runs the Dashboard and the API on sibling
hostnames (dash./api.) beside the default same-origin topology, from one
image: the API origin reaches the Dashboard at run time in a meta element,
the display host stays on the API's origin (device cookies and the offline
code cache depend on it), the API trusts its own origin as Better Auth
already does, cookies stay host-only, CORS stays narrow and becomes part of
the module UI contract, the reference proxy gains per-hostname routing, and
the deploy smoke test proves both topologies, the split one in a real
browser.

Amends ADR 0034 and extends ADR 0044.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No code changes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 07:21:17 -05:00
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
833 changed files with 146495 additions and 2085 deletions
+30
View File
@@ -0,0 +1,30 @@
# 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.
# Installed modules never enter the platform image (ADR 0048): an installation keeps them in its own
# module storage, mounted at run time. Excluding the checkout's `modules/` is what makes a build
# identical whether or not modules happen to be present in the source tree.
/modules
# 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
+96 -1
View File
@@ -1,4 +1,8 @@
# 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
@@ -6,16 +10,107 @@
# The plaintext loopback DATABASE_URL below is therefore only usable with this set.
NODE_ENV=development
# Installed modules (ADR 0048). Unset, the checkout's own modules/ is used, as module development
# and the platform's tests expect. Set, it must be an absolute path to an existing directory;
# anything else stops the API and every module command with a sanitized error. The production
# image sets /var/lib/ekklesia/modules.
# EKKLESIA_MODULES_DIR=/absolute/path/to/modules
POSTGRES_DB=ekklesia
POSTGRES_USER=ekklesia
POSTGRES_PASSWORD=ekklesia-development-only
POSTGRES_PORT=5432
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_SECRET_KEY=ekklesia-development-secret
S3_BUCKET=ekklesia-development
S3_ENDPOINT=http://127.0.0.1:8333
S3_PORT=8333
S3_REGION=us-east-1
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.
+4
View File
@@ -6,3 +6,7 @@
*.gif binary
*.webp binary
*.woff2 binary
# Reviewed license supplements are named by the SHA-256 of their exact bytes (ADR 0052), so Git must
# never rewrite their line endings.
packages/module-pack/license-supplements/** -text
+244 -14
View File
@@ -1,18 +1,13 @@
name: CI
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
# correct on a supported release and on GitHub Actions, but on this server they are inert:
# The server runs Gitea 1.27.2 (docs/development/security.md, "What this server honours"):
#
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
# is upgraded and configured, the token's authority is whatever the server grants — not what
# this file requests.
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
# * `concurrency:` is honoured: a newer push to a pull request cancels the run it supersedes, so a
# cancelled run's own cleanup is never guaranteed (see the CI stack sweep in each stack job).
# * `permissions:` is supported by 1.27 with Gitea's own scopes, but what the job token can do on
# this server is not yet proven, so nothing here relies on it being read-only.
# * `timeout-minutes:` is no longer listed as unsupported, but is not verified here either;
# teardown relies on the stack sweep and the runner's own reaper.
#
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
# itself. See docs/development/security.md.
@@ -63,6 +58,9 @@ jobs:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 10
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: infrastructure-smoke
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -72,13 +70,73 @@ jobs:
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
- name: Prove the sweep removes exactly what it should, against real Docker resources
run: bash scripts/test-ci-sweep.sh
- name: Exercise PostgreSQL and S3 infrastructure
run: bash scripts/smoke-infrastructure.sh
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
migration-smoke:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: migration-smoke
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
- 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
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
package-release:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 15
# What the gate proved, for the job that consumes its artifact to check it received exactly that.
outputs:
version: ${{ steps.release.outputs.version }}
revision: ${{ steps.release.outputs.revision }}
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -101,5 +159,177 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Exercise transactional migrations against PostgreSQL
run: pnpm test:migrations:integration
# ADR 0053's package release gate: build the public package train from this commit, build it
# again from a second checkout and require byte-identical artifacts, then install exactly those
# tarballs (offline, no registry) into a module outside this repository and prove it builds,
# packages, and verifies. Nothing is published and no credential exists here. A future publish
# job must depend on this job and publish only the artifacts it proved.
- name: Build, reproduce, and consume the public package train
run: pnpm test:package-release --keep package-train
- name: Describe the proven release train
id: release
run: |
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
manifest="$release/ekklesia-package-train.json"
{
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
} >>"$GITHUB_OUTPUT"
cat "$manifest"
# The exact release artifacts, for the jobs that consume them, from a directory whose name does
# not start with a dot: the artifact actions leave hidden files out. Version 3 of the artifact
# actions: version 4 refuses any server that is not github.com, which this Gitea is not, while
# Gitea implements the protocol version 3 uses. A consumer checks the files, not the wrapper.
- name: Hand the release train on
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: package-train
path: package-train/
if-no-files-found: error
retention-days: 7
# The publication tool, given exactly the artifact the package-release job proved, against a
# disposable Gitea 1.27.2 (scripts/test-package-publication.sh). Never the real registry, and no
# credential: the disposable registry's accounts are created and discarded inside the job.
package-publication:
needs: package-release
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: package-publication
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
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: Receive the release train the gate proved
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: package-train
path: package-train/
- name: Check it is exactly that train
env:
EXPECTED_MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
run: |
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
actual="$(sha256sum "$release/ekklesia-package-train.json" | cut -d' ' -f1)"
if [[ -z "$EXPECTED_MANIFEST_SHA256" || "$actual" != "$EXPECTED_MANIFEST_SHA256" ]]; then
echo "The received release manifest is not the one the package-release job proved." >&2
exit 1
fi
(cd "$release" && sha256sum --check --strict SHA256SUMS)
- name: Publish it to a disposable Gitea, and prove the round trip and recovery
run: bash scripts/test-package-publication.sh --release "$(find package-train -mindepth 1 -maxdepth 1 -type d)"
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
image-build:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 20
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: image-build
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
# has no buildx plugin.
DOCKER_BUILDKIT: "1"
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
# The one generic platform image (ADR 0044, ADR 0048), proved rather than assumed: it contains no
# module, no module template, and no module manifest; its module storage mount point is empty;
# EKKLESIA_MODULES_DIR names it; and a checkout with a populated modules/ directory builds an
# identical runtime file system. Build only — no registry login, no credentials, no push, no tag
# anywhere but this runner's local image store. Tagged per run and attempt, and labeled like
# every CI stack, so this job's final step removes the images even if a build is killed.
- name: Build the generic platform image and prove it holds no modules
run: bash scripts/check-image.sh
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
deploy-smoke:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 30
# Both topologies (ADR 0045), each on the one generic image: same-origin, the default every
# installation gets, and split, which installs a test-only module into module storage, removes
# it, and restores it (ADR 0048), and drives a real headless browser across the two origins.
strategy:
fail-fast: false
matrix:
topology:
- same-origin
- split
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: deploy-smoke-${{ matrix.topology }}
DOCKER_BUILDKIT: "1"
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
# The reference deployment end to end (ADR 0044): the generic platform image, base + proxy with
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
# database TLS through the proxy. Every secret is generated for this run and discarded with
# it; nothing is pushed, published, or logged in to.
- name: Smoke-test the reference deployment (${{ matrix.topology }})
run: bash scripts/smoke-deploy.sh
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
+133
View File
@@ -0,0 +1,133 @@
name: Publish packages
# Publishes one public @ekklesia/* package train (ADR 0053, docs/development/releases.md) to the
# npm registry: exactly the artifacts this run's package-release gate proves, and nothing else.
#
# Only ever started by hand, on `main`, by someone with write access to this repository: no pull
# request or push can start it. It needs the version-set commit of a release (`version` must equal
# the platform version there), and the repository secret EKKLESIA_PACKAGES_PUBLISH_TOKEN, a token of
# the dedicated publisher account with only the `write:package` scope. Packages are publicly
# readable (ADR 0054); publishing is what this protects.
on:
workflow_dispatch:
inputs:
version:
description: The package train version to publish, equal to the platform version on main.
required: true
type: string
permissions:
contents: read
# Never cancel a publication part way, and never run two at once. A rerun resumes safely.
concurrency:
group: publish-packages
cancel-in-progress: false
env:
# The only registry-specific setting: where the train is published.
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
jobs:
package-release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
version: ${{ steps.release.outputs.version }}
revision: ${{ steps.release.outputs.revision }}
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
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
# The same credential-free gate as CI: build, reproduce, and consume the train.
- name: Build, reproduce, and consume the public package train
run: pnpm test:package-release --keep package-train
- name: Describe the proven release train
id: release
run: |
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
manifest="$release/ekklesia-package-train.json"
{
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
} >>"$GITHUB_OUTPUT"
# Artifact actions version 3: version 4 refuses any server that is not github.com. The directory
# name must not start with a dot: the artifact actions leave hidden files out.
- name: Hand the release train on
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: package-train
path: package-train/
if-no-files-found: error
retention-days: 90
publish:
needs: package-release
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# Only for the publication tool itself: nothing is built or installed in this job.
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Receive the release train the gate proved
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: package-train
path: package-train/
# The tool validates the whole train against its manifest, that manifest against the gate's,
# its version against the request, and its revision against this commit, before it sends
# anything; then publishes, verifies every package byte for byte, and moves `latest` last.
- name: Publish the exact release train
env:
EKKLESIA_PACKAGES_PUBLISH_TOKEN: ${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}
TRAIN_VERSION: ${{ inputs.version }}
TRAIN_REVISION: ${{ github.sha }}
MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
run: |
node scripts/publish-package-train.ts \
--release "$(find package-train -mindepth 1 -maxdepth 1 -type d)" \
--registry "$EKKLESIA_PACKAGES_REGISTRY" \
--version "$TRAIN_VERSION" \
--revision "$TRAIN_REVISION" \
--manifest-sha256 "$MANIFEST_SHA256" \
--record publication-record.json \
--require-public-read
- name: Keep the publication record
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: package-train-publication-record
path: publication-record.json
if-no-files-found: error
retention-days: 90
+12
View File
@@ -1,4 +1,8 @@
node_modules/
# pnpm's content-addressed package store. pnpm keeps it beside node_modules on the same file system,
# so when the checkout is its own mount (a CI runner's workspace volume, a bind-mounted container),
# `pnpm install` creates it here, at the checkout's root. A package cache, never source.
.pnpm-store/
.turbo/
coverage/
dist/
@@ -16,3 +20,11 @@ generated/*
core/.identity-schema-reference.ts
core/.identity-schema-check-*
# The packager tests copy fixture modules here (packages/module-pack/tests/support/fixture-module.ts).
packages/module-pack/.fixtures/
# `pnpm packages:release` writes package release trains here (scripts/package-release.ts).
.package-release/
# CI keeps the proven release here to hand it on as a workflow artifact (scripts/test-package-release.sh --keep).
/package-train/
+12 -5
View File
@@ -15,11 +15,11 @@ monorepo:
- **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 may also own its own UI and declare a compiled
`entrypoints.ui` artifact, authored in React per ADR 0022 against `@ekklesia/module-ui-sdk`. No
host mounts that entrypoint yet, and `pnpm boundaries` still rejects every non-React UI framework
and non-`.tsx` presentation source inside a module package.
- **Surfaces** (future `surfaces/*`) own the containing application shell, navigation, and every
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.
@@ -78,6 +78,13 @@ ever disagree:
- `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 test:package-release` — builds the public `@ekklesia/*` package train, proves it
reproducible, and proves a standalone module consumes exactly those tarballs. Publishes nothing.
Run it for any change to the public packages or their release tooling.
- `pnpm test:package-publication` — publishes a release train to a disposable Gitea (Docker) and
proves the publication tool. Never the real registry. The real publication is the manual
`Publish packages` workflow only; never run `scripts/publish-package-train.ts` against the real
registry without the user's explicit intent.
- `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
+36 -9
View File
@@ -9,7 +9,8 @@ Before opening a change, run:
pnpm verify
```
Migration-runner changes also require the disposable live PostgreSQL suite:
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
@@ -34,20 +35,46 @@ pnpm infrastructure:config
private source paths.
- Use `workspace:` ranges for internal package dependencies.
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
`pnpm boundaries`. UI source lives at `src/ui/index.tsx` and `pnpm build` compiles it to the
artifact the manifest declares, via `@ekklesia/module-ui-build` (ADR 0023) — module authors write
no bundler configuration. Styling is React inline style objects only for now, and no host mounts a
UI entrypoint yet.
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
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
installed module UIs through `GET /api/v1/module-ui` and mounts them with
`@ekklesia/module-ui-host`, and it statically imports no module (ADR 0026).
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.
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+669 -40
View File
@@ -11,15 +11,22 @@ Phase 4C (Core authorization), Phase 5A (module authorization integration), Phas
transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
against an independent security audit) is complete: all five passes have landed and the final
independent whole-repository re-audit has closed, under the current trusted-contributor,
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** The operational
not-yet-deployed threat model.** Phase 6 completed through 6D, and **Phase 7A passes 1 (the minimal
operational module lifecycle), 2 (the
module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct
browser object delivery), 5 (native module realtime transport), and 6 (the authenticated display/
kiosk host) have landed. Phase 7B pass 1 adds the Dashboard's host-owned browser sign-in, sign-out,
session-expiry handling, and authenticated-shell transition.** The operational
requirements that remain are prerequisites for two specific *future* activities — executing
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
untrusted CI contributions, and a public production launch — and neither gates ongoing product
development; see the
pass 5 closure below and
[Security model and deployment trust boundaries](docs/development/security.md). The repository includes
public module contracts, independently versioned capabilities, static module manifests,
deterministic automatic discovery, compiled server loading, a staged capability registry,
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
schema aggregation, and a cloneable backend-only module template. Migration artifacts have
schema aggregation, and a cloneable module-product template with optional primary and display UI.
Migration artifacts have
module-owned Drizzle contracts, deterministic offline discovery, explicit destructive-change
declarations, and exact checksums. Core provides read-only applied-history planning and a locked,
whole-batch transactional PostgreSQL runner with exact external destructive approval matching. A
@@ -28,6 +35,48 @@ concurrency and release, exact destructive approvals, and sanitization against t
adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only
migration-planning services into one concise readiness report.
**Ekklesia ships one generic image, and installed modules live outside it
([ADR 0048](docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)).** The
production image built from [`deploy/Dockerfile`](deploy/Dockerfile) contains zero feature modules
and is the same for every installation. Installed modules live in installation-owned module storage,
selected by `EKKLESIA_MODULES_DIR` (`/var/lib/ekklesia/modules` in the image, the checkout's
`modules/` when unset) and mounted read-only by the reference deployment. Each installed module is a
self-contained directory carrying its own production dependencies. Installing, updating, or removing
a module needs only a controlled restart and, when it has migrations, an explicit plan and apply.
Nothing is hot loaded. Removing a module's code keeps its data and its stored state; its applied
migration history is retained and reported, and blocks no other migration. A module is distributed
as one **Ekklesia Module Package v1**
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
bundled, and checked by Core's verifier before it is installed. Two targets exist:
- the portable `any` target, for pure-JavaScript modules;
- `linux-x64-glibc`, for modules with a native dependency such as `sharp`. Its native runtime
packages are carried as real files under the package's `node_modules`, and the verifier checks the
host before anything is loaded.
Every package records the module's validated SPDX expression and carries its explicitly
supplied `LICENSE` bytes; dependency material remains separate in `THIRD_PARTY_NOTICES`. ABI-v1
packages may bundle a module-owned Drizzle version without coupling it to Core;
legacy-v8 packages retain their transitional exact-version check. Where a redistributed dependency
omits licence material, the packager may add reviewed licence supplements
([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). A native package that
redistributes LGPL or MPL code is distributed publicly only with its corresponding-source artifact,
which `ekklesia-corresponding-source` builds and gates
([ADR 0055](docs/architecture/decisions/0055-corresponding-source-artifacts.md)). Purge, other
operating-system or CPU targets, signing, and any installer or marketplace do not exist yet.
A module can be built in its own repository from Ekklesia's public module-development packages
([ADR 0053](docs/architecture/decisions/0053-standalone-module-development-packages.md)):
a curated `@ekklesia/module-sdk`, `@ekklesia/module-database`, the UI packages, and the build and
packaging tools. They are released as one coordinated train of tarballs with a release manifest,
and CI proves on every run that two builds of a commit are byte-identical and that a module outside
the repository installs, builds, packages, and verifies from those artifacts alone. A manual
workflow, the only way to publish, publishes exactly those artifacts to the npm registry and
verifies every package byte for byte. Anyone may read and install the packages before launch
([ADR 0054](docs/architecture/decisions/0054-public-package-reads-before-launch.md)). The first train
published will be the next platform release
([Releases and the public package train](docs/development/releases.md)).
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
`ekklesia_core_verification`), a first-class Core-owned migration source that coexists with
@@ -45,10 +94,13 @@ order never grants elevation — but is now **disabled by default, and cannot be
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
[API](docs/development/api.md) and
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
**No email verification, password recovery, or Dashboard exists yet** — that, along with
production approval-provider composition, jobs, physical storage provisioning, surfaces, and
feature modules, remains later vertical slices. Public registration is not yet ready for a real
public launch.
Email verification and password recovery now exist (Phase 7C pass 2, below); invitation and operator
enrollment still do not.
Public registration remains disabled by default and prohibited in production — and a fresh
production installation now has a supported way to get its first administrator anyway: Phase 7B
pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow,
but not a finished product UI or a production asset-hosting deployment; jobs, production
approval-provider composition, feature modules, and the public Website remain later slices.
Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store
for platform owners, explicit grants, and relationship-derived grants; a smallest-possible
@@ -61,7 +113,7 @@ accounts/API tokens remain later work. See [Authorization](docs/development/auth
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
Phase 5A wires module-declared permissions into that same authorization system: the module manifest
(version 3 at the time, version 4 today) declares permissions as complete
(version 3 at the time, version 6 today) declares permissions as complete
`{id, baseline, description}` declarations (never a bare string), and `apps/api` discovers every installed module and composes one immutable platform
permission registry — Core's own declarations plus every module's — before constructing its
database pool, Better Auth instance, or HTTP server. A module's declared permission is then
@@ -208,9 +260,10 @@ undeployed state, and the launch checklist in a single place. **CI is accepted a
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
execute on it automatically. What remains is operational and cannot be proven from this repository —
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
configuring and proving job-token authority server-side (the server now runs Gitea 1.27.2, which
honours `concurrency` and documents `permissions`, but neither token limits nor `timeout-minutes` are
verified on it yet), enforcing timeout and teardown in a trusted provisioner/reaper, and proving
disposable runner isolation. Those are
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
gates a **public launch**; moving future public contribution workflows to a service with isolated
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
@@ -230,15 +283,15 @@ never a dependency. The long-term goal is one module implementation hosted by Ek
by a standalone application — never two codebases for one domain. See
[ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md).
**What that pass shipped is the static contract only.** `manifestVersion: 4` adds one
optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
**What that pass shipped at its checkpoint was the static contract only.** `manifestVersion: 4`
added one optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
during discovery exactly like `server` and `schema` — inside the package, real file, no source
TypeScript. Discovery stays inert: **nothing imports, mounts, or renders that entrypoint**, exactly
like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest is a
one-character edit; version 3 is rejected outright rather than reinterpreted, as version 2 was
before it. **The Dashboard shell, a UI runtime or renderer, navigation
registration, Dashboard grouping, a standalone host, a module installer or marketplace, hot-loading,
and every feature module do not exist.** Restart-based discovery, explicit operator-invoked
TypeScript. Discovery stayed inert: **nothing imported, mounted, or rendered that entrypoint**,
exactly like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest was a
one-character edit; version 3 was rejected outright rather than reinterpreted, as version 2 was
before it. The Dashboard shell, runtime mounting, and navigation named as absent at that checkpoint
landed in subsequent Phase 6 passes. A standalone host, module installer or marketplace, hot-loading,
and every real feature module still do not exist. Restart-based discovery, explicit operator-invoked
migrations, and the trusted compiled-module model are unchanged.
Phase 6B pass 1 answers the question Phase 6A deliberately left open: **what a module UI actually is,
@@ -252,8 +305,9 @@ returns an unmount function. A host needs an element and a context, never a matc
The new `@ekklesia/module-ui-sdk` package (no runtime dependencies; its one dependency on
`@ekklesia/contracts` is type-only) carries that contract for module authors and hosts alike. The
host context is deliberately four members — `moduleId`, `api`, `principal`, `permissions` — and
nothing else. **A module UI asks its host for its own API by relative path** (`api.request("posts")`)
host context is deliberately five members — `moduleId`, `api`, `principal`, `permissions`, and
`realtime` — and nothing else. **A module UI asks its host for its own API by relative path**
(`api.request("posts")`)
and never constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private deployment detail; that
one rule is what keeps a future standalone host possible, and the test suite proves it by mounting
one fixture artifact under two hosts with different origins and URL shapes. **Client-side permission
@@ -278,7 +332,8 @@ artifacts cannot drift.
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
sharing one runtime later is a build optimization that does not change the contract. Styling is React
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar
inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
and installing a module or starting the API never triggers one.
@@ -337,15 +392,17 @@ GET /api/v1/module-ui → which module UIs may be mounted, and where
@ekklesia/module-ui-host → dynamic import, identity check, mount into a host-owned container
↓
context.api.request("records") → /api/v1/modules/example/records
context.realtime.subscribe("state") → host-owned live state transport
```
The new inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version,
artifact URL, module API root, and the viewer's granted permissions **for that module** — and nothing
else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because navigation metadata is a
design that does not exist yet. It is authenticated (`core.modules.ui.read`, baseline
The inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version,
artifact URL, module API root, module realtime root, and the viewer's granted permissions **for that
module** — and nothing else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because
navigation metadata is a separate design. It is authenticated (`core.modules.ui.read`, baseline
`authenticated`) precisely because it is the module enumeration the anonymous artifact route
deliberately withholds. Both URLs are produced by the server's own canonical helpers, so the browser
never rebuilds Ekklesia's module prefixes and either can change without touching host or module code.
deliberately withholds. All three URLs are produced by the server's own canonical helpers, so the
browser never rebuilds Ekklesia's module prefixes and any can change without touching host or module
code.
**The shell statically imports no module.** Every module it can mount arrives at run time from the
inventory, and its bundle is checked for exactly one dynamic import — the generic loader's — with no
@@ -355,10 +412,421 @@ still refused `403` when the UI calls the API anyway. The host owns the containe
and every failure; a broken module costs its own panel, not the shell. See
[ADR 0026](docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md).
**This is not the Dashboard.** There is no navigation model, grouping, folders, icons, ordering, home
screen, design system, or client-side routing — the selector is a list of module ids. Sign-in UI, a
production CSP and deployment for the built shell, asset publication beyond the UI entrypoint, React
runtime sharing, the standalone host, and every real feature module also do not exist.
Phase 6D pass 1 gives that shell a real **navigation model** — and pins down the rule it could
easily have broken. A church sees `Media → Livestream, Video` and `Planning → Calendar`, while the
modules underneath stay exactly what they were: flat, independently versioned products with no
parent, no owner, and no folder-derived dependency.
**Grouping is presentation configuration, not module structure.** A group is a label on a rendering
bucket: no manifest, no version, no artifact, no API, no lifecycle, and nothing depends on it.
Placing Livestream and Video in one folder creates no relationship between them, and moving a module
changes one row. *Livestream depends on Video because it calls Video's functionality* is a real
dependency that orders startup; *Livestream is shown under Media* orders nothing. That distinction 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 come out byte-identical every time.
**No manifest change, and no version 5.** `manifest.name` has been required since version 1 and is
already the module's product identity, so the shell simply renders "Livestream" instead of
`livestream`. `suggestedGroup` was 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
would risk a module's suggestion overwriting an administrator's decision.
Two Core tables (migration `0003`) hold groups and placements — server-side, because a church
configures its Dashboard, not one browser; platform-global, because Ekklesia has no organization
model yet. `GET /api/v1/dashboard/navigation` returns the resolved view, layered **on top of** the
host-neutral module UI inventory rather than merged into it, embedding each generic descriptor
untouched. Reading needs the same permission the inventory does; organizing needs a new platform
permission, `core.dashboard.navigation.manage`. A newly installed module appears immediately as
ungrouped with no initialization write; an uninstalled module's placement is retained so reinstalling
restores its position; deleting a group ungroups its modules through the database rather than
application cleanup. See
[ADR 0027](docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md).
Phase 7A pass 1 gives the platform an **operational module lifecycle** — the smallest one that is
actually useful, and nothing beyond it. Until now the accepted module set was exactly "whatever was
found on disk at startup", and `ModuleLifecycleStage` named nine states that nothing produced or
consumed. That vocabulary is replaced by two axes that real code produces:
```text
configuration enabled / disabled persistent, administrator-owned
runtime running / disabled / blocked observed per boot
```
**Enablement is host configuration, and a module can never see it.** One Core table (migration
`0004`) holds `module_id` and `enabled` — no install source, download URL, update channel, signature,
or JSON blob, because those are the columns an installer would need and their absence is what stops
one being built by accident. A module never declares or reads its own enabled state, and is never
told it is enabled: its host loads and starts it, or does not. That is what keeps the same module
implementation runnable under a future standalone host.
**A newly discovered module is enabled, and the default is the absence of a row.** Deploying a module
is still sufficient to run it — no initialization write, no database edit — mirroring the rule that a
module with no Dashboard placement is still visible. An explicit record always wins, in both
directions, and a record for a module that is no longer deployed is retained and ignored, so
redeploying restores the decision.
**Required dependencies block; optional ones never do.** Disabling `storage` does not silently
re-enable it because something needs it — it makes every module that *requires* it ineligible,
transitively, with a structured reason (`required_dependency_disabled` →
`required_dependency_blocked`) naming the module an administrator must act on. A required *capability*
dependency blocks identically, because its only provider is a module. An optional dependency being
disabled is the same situation as it never being installed: the dependent starts, and
`capabilities.resolve(...)` returns `undefined`.
**Exposure follows eligibility, structurally.** `startApi` hands the same eligible list to the module
runtime and to the UI publication registry, so a disabled module is never imported at all: no hooks,
no routes (an unmounted path gets the ordinary `404` — there is no per-request enablement check
anywhere), no capabilities, no published artifact, and no entry in the browser inventory or Dashboard
navigation. Its **migrations are still discovered and applied**, because deployed schema ownership is
not runtime state, and its **permissions stay registered**, so grants survive an enable/disable cycle.
**Startup stays all-or-nothing, deliberately, and changes need a restart.** One module that fails to
start still takes API startup down; that guarantee is what ADR 0025/0026/0027 rest on, and inverting
it is a separate design. There is consequently no `failed` runtime state — no live process could
report one. Enablement is what makes a broken module recoverable, with `pnpm modules:disable --
--module <id>` as the out-of-band path, since the HTTP API is served by the process a bad module
takes down. `PUT /api/v1/module-lifecycle/<id>/enabled` writes a decision and reports honestly that
the process has not changed:
```json
{ "configured": { "enabled": false, "explicit": true },
"runtime": { "state": "running" },
"restartRequired": true }
```
Nothing is hot-unloaded to make a button feel immediate. `running` means a module's `start` hook
completed and **nothing more** — a module that starts and then cannot reach its object storage has a
health problem, not a lifecycle problem, and this pass adds no health-probe contract. `pnpm doctor`
gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading
logs. Reading and changing both require the new restricted platform permission
`core.modules.lifecycle.manage`. Disabling a module is an **operational** control, not a sandbox: its
code is still on disk and still trusted. There is still **no installer**. See
[ADR 0028](docs/architecture/decisions/0028-module-operational-lifecycle.md).
Phase 7A pass 2 adds the **module configuration and secrets contract** — the thing standing between
Announcements and the module system, since that product currently has zero environment variables and
compiles a LAN IP address into its source.
A module declares what it needs configured in its manifest, and receives it as a frozen, validated
snapshot:
```json
"configuration": [
{ "key": "slideSeconds", "type": "integer", "default": 7, "minimum": 1, "maximum": 600 },
{ "key": "bucket", "type": "string", "required": true },
{ "key": "storageCredential", "type": "secret", "required": true }
]
```
```ts
start({ config }) {
const slideSeconds = config.getInteger("slideSeconds");
const credential = config.getSecret("storageCredential");
}
```
**Declaration is static, and that is the whole point.** The platform validates configuration
*before* deciding whether a module may start, so an enabled module missing a required value is
reported `blocked · configuration_missing · bucket` — never imported, never able to take startup down
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
manifest is version 8 after ADR 0050 added the optional database ABI declaration. Version 8 is a
hard cutover; a version 7 source module or package must be updated and rebuilt.
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
no key management, so a `secret` key's value is supplied by the deployment
(`EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`) and never touches PostgreSQL. The module never sees
that variable name — it calls `config.getSecret("storageCredential")` — which is exactly what lets a
future standalone host supply the same declared secret from somewhere else. A `secret` declaration has
no `default` field, the write API refuses secret material outright, and the HTTP view has no `value`
field for one, so a secret is absent from responses *by construction* rather than filtered by a branch
somebody could delete.
Six types (`string`, `integer`, `boolean`, `enum`, `url`, `secret`), bounded constraints, no nesting,
no regular expressions — a validation vocabulary, not a form-builder DSL. Nothing is coerced:
`{"slideSeconds": "7"}` is a `400`, and a stored value that fails its own declared constraints blocks
the module rather than being quietly replaced by the default. Ordinary values live in one Core table
(migration `0005`), one row per `(module_id, key)`; a key the current manifest no longer declares is
retained and ignored so a downgrade never destroys an operator's value.
Changes take effect at the **next restart**, and `GET`/`PUT /api/v1/module-configuration/<id>` report
`restartRequired` rather than pretending otherwise — a sibling prefix, because `config` is a legal
module route segment. Both verbs need the new restricted `core.modules.configuration.manage`,
deliberately separate from the lifecycle permission: running a module and configuring it are different
authorities. A setting that must change *without* a restart is the module's own domain state — like
Announcements' `mode` toggle — not platform configuration.
**There is no settings UI**, and module UI receives no configuration at all in this phase. Endpoints
and credentials for shared infrastructure stay platform concerns: a module declares which *bucket* it
needs, not an S3 endpoint, leaving room for the object storage service Core still lacks. See
[ADR 0029](docs/architecture/decisions/0029-module-configuration-and-secrets.md).
Phase 7A pass 3 turns `manifest.storage.buckets` — declarable since manifest version 1 and inert ever
since — into a real **host-provided object-storage capability**. This is what stands between
Announcements and the module system: it currently fetches `http://10.40.0.2:9000/announcements`, parses
S3 XML by hand, and hands the browser direct S3 URLs.
A module declares a **logical** bucket and gets a scoped facade:
```json
"storage": { "buckets": [{ "id": "announcement-assets" }] }
```
```ts
const assets = context.storage.bucket("announcement-assets");
const object = await assets.get("slides/welcome.png");
return new Response(object.body, { headers: { "content-type": object.contentType } });
```
**The contract names no provider.** No endpoint, region, credential, or physical bucket is reachable
from it — not as a field, not as a type, not as a string. That is what lets the same module run under
Ekklesia and under a future standalone host backed by its own SeaweedFS, a shared one, MinIO, or AWS
S3. A module that configures its own S3 endpoint is a module welded to one deployment, which is the
mistake this pass exists to prevent.
Core's first provider speaks the S3-compatible protocol through the AWS SDK. SeaweedFS is the intended
target ([ADR 0004](docs/architecture/decisions/0004-replace-unmaintained-minio.md)) and already runs in
the development Compose stack — but it is a deployment choice, never an API name. Probing it directly
found a real divergence worth having caught: `CreateBucket` on an existing bucket reports
`BucketAlreadyExists`, **not** AWS's `BucketAlreadyOwnedByYou`, so treating only the AWS spelling as
success would have failed on every second boot. A live test against the real server pins it.
**Logical → physical mapping is deterministic and collision-safe.**
`ekklesia-announcements--announcement-assets-3f0c1d9a6b21`: an installation prefix (so several
deployments can share one storage service), the module and bucket ids joined by `--` — which cannot
occur inside either identifier, so `a-b`+`c` and `a`+`b-c` can never collide — and a digest over the
full untruncated identity. Two modules both declaring `assets` always get separate namespaces, and a
module can never see or choose the physical name.
**Declaring a bucket is now a real requirement.** With no provider configured, such a module is
reported `blocked · storage_unavailable` naming the logical bucket, and its required dependents block
behind it through the existing resolver — no second eligibility system. An installation with *no*
object storage remains a complete deployment; modules declaring no bucket are unaffected, which is why
the template now declares none.
**A storage outage is not a lifecycle event.** Eligibility asks whether storage is *configured*, never
whether it answered. Bucket creation is lazy on first use rather than at startup, so a storage service
being down can never stop the platform from booting — and a running module whose object store blinks
stays `running`, catches a structured error, and retries, exactly as Announcements does today. Nothing
ever deletes a bucket, on disable or otherwise.
Credentials are deployment-supplied (`S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`,
`S3_BUCKET_PREFIX`), never persisted, never routed through module configuration, and never visible to
modules, module UI, diagnostics, or any API response — errors are translated to a closed code set that
carries no endpoint, bucket, request id, or XML. This also retires the transitional `bucket`
*configuration* key ADR 0029 flagged: the manifest declaration is the requirement.
Multipart upload is deliberately deferred, and the provider is not yet "production-ready" by
ADR 0004's own bar. See
[ADR 0030](docs/architecture/decisions/0030-host-provided-module-object-storage.md).
Phase 7A pass 4 takes Ekklesia **out of the data path** for assets a browser is going to fetch
anyway. A module asks its logical bucket for a temporary browser URL and returns it; the browser
fetches the object from the storage service directly:
```ts
const { url, expiresAt } = await context.storage
.bucket("announcement-assets")
.createReadUrl("slides/welcome.png");
```
The contract still names no mechanism — no signing, no endpoint, no expiry format — so a host with no
S3 in it could satisfy the same method. 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
for one: storage bounds *reach*, never *identity*. Lifetimes run from one minute to six hours,
fifteen minutes by default, and out-of-range requests are refused rather than clamped. Modules
persist logical keys and generate URLs on demand; a stored URL would be a stored credential.
This is what makes media practical. A `Range` request against a delivery URL is answered by the
storage service directly — verified `206` with a correct `Content-Range` — so byte-range seeking needs
no proxy in Core. Signing is local, so a slideshow builds one URL per slide with no round trips, and
repeated calls inside a window return a byte-identical URL so browser caches and URL-keyed DOM
elements hold.
Because an S3-compatible signature covers the request's `Host`, a URL cannot be signed for one origin
and rewritten to another. The origin a browser uses is therefore a signing input
(`S3_DELIVERY_ENDPOINT`), defaulting to `S3_ENDPOINT` — so the standard deployment configures nothing
and delivery simply works. A reverse proxy in front of storage must pass the original `Host` through
unchanged; it may terminate TLS, because the scheme is not signed. Buckets stay private: an unsigned
request for the same object is refused, and a test asserts it. **Signed write and delete URLs are
deliberately deferred.** See
[ADR 0031](docs/architecture/decisions/0031-direct-object-delivery.md).
Phase 7A pass 5 adds native, host-neutral module realtime
([ADR 0032](docs/architecture/decisions/0032-native-module-realtime-transport.md)). During
`register`, a module declares a small, module-local channel and the permission that protects it;
during `start`, running work, and HTTP handlers, it publishes bounded JSON state through its own
scoped publisher. The publisher is inert by the time that module's stop hook begins. Core keeps only
the latest state and one pending update per subscriber. A module
must publish an initial state before startup completes, so every accepted subscriber receives one
atomic authoritative snapshot and then monotonically sequenced updates without a GET-then-subscribe
race. Slow clients never block publishers and intermediate states may be coalesced.
Ekklesia carries that contract over permission-gated Server-Sent Events, with cookie authentication,
15-second transport-owned keepalives, a five-minute reauthorization cycle, browser-native reconnect,
and a fresh snapshot on every reconnection. The module UI API remains transport-neutral:
```ts
const subscription = context.realtime.subscribe<SlideshowState>("state", {
onSnapshot: (state) => render(state),
onUpdate: (state) => render(state),
});
// Return this from `mount`, or call it from your unmount cleanup.
subscription.close();
```
There is no durable replay, global topic namespace, cross-module subscription, query-string token,
Socket.IO contract, or MQTT integration. Direct object-delivery URLs are opaque JSON strings and
pass through realtime unchanged.
Phase 7A pass 6 adds the smallest complete **authenticated display/kiosk host**
([ADR 0033](docs/architecture/decisions/0033-display-kiosk-host-contract.md)). Manifest version 6
adds an optional, independently declared display role:
```json
{
"entrypoints": {
"display": {
"artifact": "./dist/display/index.js",
"permission": "announcements.display.view"
}
}
}
```
The permission must be declared by that module and have an `authenticated` or `restricted` baseline. Primary UI and display UI are independently optional, must
use paths distinct from every other entrypoint, and use the same
`defineModuleUi({ id, version, mount })` ABI and five-member `ModuleUiHostContext` with **no mode
flag**. The canonical builder reads fixed sources (`src/ui/index.tsx` and
`src/display/index.tsx`) and atomically emits every declared role.
Phase 7B pass 2 gives those screens an identity of their own
([ADR 0036](docs/architecture/decisions/0036-managed-device-identity-and-pairing.md)), so a
television no longer needs a person signed into it. Ekklesia now recognizes **three** principal
classes — anonymous, authenticated human, and managed device:
```text
screen opens /display/announcements → no identity → shows a 6-digit code
administrator types the code in the Dashboard, names the screen, approves
screen collects an HttpOnly credential → display and realtime work
administrator revokes → the screen loses access and shows a code again
```
**A device is not a user.** 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 a screen on a wall. Device authority runs through the same authorization
evaluator as everyone else's, and approval grants exactly the one display permission the host derived
from the module's own declaration. `/api/v1/principal` reports a device as anonymous on purpose: a
device cookie cannot open the Dashboard.
**The code is not the credential.** Six digits are printed across a television in a public hallway,
so they are treated as public: they identify a request to an administrator and yield nothing on their
own. Collecting the credential also requires an HttpOnly claim cookie only the requesting browser
holds, and approving requires the restricted `core.devices.manage` permission. The credential itself
is stored only as a SHA-256 digest, never logged, never in a URL, and never readable by the module
display code running in that same page.
Revocation is a server-side fact checked on every request, not a plea that a remote browser forget
something; re-pairing creates a **new** identity so an old credential can never come back. A revoked
screen fails its next request immediately, though an already-open realtime stream is bounded by the
existing five-minute forced reconnect rather than closed instantly. **QR scanning, per-device content
targeting, device groups, and mobile dashboards are deliberately deferred** — a future QR simply
encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device
identity at all.
Phase 7C pass 1 adds the platform's first **outbound email transport**
([ADR 0037](docs/architecture/decisions/0037-host-provided-outbound-email.md)). Ekklesia could
authenticate people and pair devices but could not send a single message, which is what blocked
email verification, password recovery, and invitations alike. Email is now a host-provided
infrastructure service with a provider seam, exactly as object storage is: feature code says *send
this transactional message* and the provider owns the host, port, TLS, credentials, 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, not a platform dependency.
The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or
HTML, and an optional reply-to. There are no attachments, headers, CC, BCC, or templates, and
crucially **no `from` field at all** — the sender is host configuration, so no feature can choose who
mail appears to come from. Control characters in addresses, subjects, and display names are refused
rather than stripped, which is what closes header injection.
Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than inferred from a
boolean and a port, because that pairing is genuinely ambiguous and silently permits a plaintext
session carrying a password. `starttls` **requires** the upgrade, production refuses `plaintext`, and
certificate verification has no off switch. SMTP credentials are optional **as a pair**, since a
relay authorized by source IP has neither — and the password is a deployment secret that never
reaches PostgreSQL, module configuration, a log, a diagnostic, or an error.
Connection, greeting, and socket timeouts are all explicit, so a silent server returns a structured
error rather than hanging. **Nothing retries**: a failure after the message data has gone out is
ambiguous, and retrying it would send someone several copies of the same reset email — so this is a
single bounded attempt and deliberately not an exactly-once promise. There is **no
`POST /api/v1/send-email`** and no route that touches the provider.
Email remains **optional**: an installation with no SMTP configured is a complete deployment.
`pnpm doctor` reports the configuration and can probe connectivity **without sending anything**,
while stating plainly that a successful connection does not establish deliverability. See
[Outbound email](docs/development/email.md).
Phase 7C pass 2 completes the human identity lifecycle over that transport
([ADR 0038](docs/architecture/decisions/0038-email-verification-and-password-recovery.md)). **A
verified email address is a precondition for holding a session**: the identity system refuses an
unverified sign-in after checking the password and before creating one, so a correct password on an
unverified address carries exactly the authority of no password at all — which is why no
"unverified principal" exists and `/api/v1/principal` is unchanged. Better Auth owns every token;
Ekklesia adds no token table, no token hashing, and no expiry clock. Verification and reset links are
rebuilt from a validated Dashboard origin rather than from anything a request supplied, so no caller
can steer where a recipient lands, and following a verification link deliberately mints **no
session** — mail scanners dereference links. Password reset revokes that person's other sessions and
leaves managed devices untouched. Password recovery answers identically for an address with an
account, without one, already verified, or undeliverable — including when SMTP is broken, which is
why the delivery adapter never throws.
**The initial platform owner is the one exception**: `pnpm identity:create-initial-owner` creates
that account already verified and has no mail prerequisite at all, so a freshly provisioned server
can be signed in to before any relay exists. Normal production use of verification and recovery does
require configured outbound email. **Public signup remains disabled** — verification is not a licence
to self-register — and invitations and user administration are still not built. See
[Identity](docs/development/identity.md).
Ekklesia publishes the display bundle at
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
`GET /api/v1/module-displays/<module-id>`. The display surface is a separate full-viewport browser
application with no Dashboard chrome, no static module imports, and manual retry on failure. Only
eligible, running modules receive routes; manifest changes take effect on restart. Artifact bytes
remain anonymous trusted code, while the page and descriptor use normal cookie authentication and
the module-owned display permission. Realtime subscriptions and opaque signed object-delivery URLs
flow through the unchanged host context. Display inventory/management, variants and targeting,
public/device identity, browser Fullscreen API behavior, a standalone host, a shared CSS system, the
Announcements migration, and Companion integration remain deferred.
Phase 7B pass 1 adds **host-owned browser authentication to the Dashboard shell**
([ADR 0034](docs/architecture/decisions/0034-host-owned-browser-authentication.md)). Anonymous
bootstrap renders an accessible email/password form; successful submission uses the existing
`POST /api/auth/sign-in/email` endpoint, whose successful browser-visible body is reduced to fixed
`{"ok":true}` while the HttpOnly cookie remains authoritative. The shell resolves
`GET /api/v1/principal` again before protected navigation and module UI, and sign-out uses the
existing `POST /api/auth/sign-out` before the same principal re-resolution. No token is stored or
passed to a module, no arbitrary callback/return URL exists, and leaving authenticated state
unmounts the active module through the generic loader's normal cleanup.
The flow intentionally offers neither signup nor password recovery: production signup remains
prohibited, no email sender exists, and the repository still lacks a supported way to create the
initial production identity required by platform-owner bootstrap. Authentication throttling is
enabled but in-memory and per API process. Host-only cookies, exact trusted origins, and Better
Auth's origin/CSRF checks remain unchanged; production Dashboard DNS/TLS/proxy/CSP and built-asset
hosting are deployment work, not guarantees added by this pass. Display device identity and the
public Website remain separate future designs.
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
and no module-management, settings, or storage UI — module enablement and configuration are an API and
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
standalone host, and every real feature module also do not exist.
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
@@ -369,6 +837,74 @@ unproven launch-blockers rather than existing guarantees. Read
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
module or exposing the API.
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
None of them is a feature module, and none of them names one.
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
badge, toast, and empty/error/loading states), every value a token reference, with real
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
exactly two shared roles to modules by name and pins both design packages beneath every other role.
The Dashboard and display install the defaults at their document root; custom properties inherit into
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
theme editor, brand builder, second theme, or component catalogue exists.**
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
is versioned with no dual acceptance; migrating is one `pnpm build`.
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
the capability system Core has had since Phase 1. `manifestVersion: 8` declares a namespaced id, its
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
means. Publication requires that another eligible module provides the capability inside the declared
range; a contribution that fails is absent rather than broken. A consumer discovers through
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
database, storage, or credential — and loading one grants its module nothing.
Phase 8 pass 4 closes the object-storage write path
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
oversize is refused before authorization, the buffered path now counts what arrived rather than
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
every contributed renderer that display mounted, network-first so a screen can never be trapped on
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
stated rather than papered over — a display cannot render module content while offline, because
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
the module's responsibility and do not exist yet.**
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
@@ -388,8 +924,18 @@ pnpm verify
`pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed
modules and dependency order, schema and migration inventories, the effective PostgreSQL transport
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage integration
state. `.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage and
outbound-email integration state.
Doctor reads its host infrastructure configuration through the same
`loadHostConfiguration(process.env)` the API executable uses, so both derive identical settings and
identical defaults from one deployment environment: configured object storage is reported as
configured, a module secret the deployment has set is reported as satisfied, and a value neither
process could accept is reported as a configuration failure rather than silently interpreted one way
by the diagnostic and another way at startup. An optional service that is genuinely absent — no
`S3_ENDPOINT`, no `EKKLESIA_SMTP_HOST` — is still a complete deployment and reported as skipped.
`.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own
development infrastructure provides is only usable with it set.
@@ -414,16 +960,24 @@ Modules
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
1 permission registered
6 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
○ Module configuration skipped
DATABASE_URL is not configured.
Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
3 migrations · checksums valid
7 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180
core:0003_dashboard_navigation_tables · 2cde27807aa4
core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b
core:0006_device_identity · 1db97b7e3ac5
PostgreSQL
✓ Database TLS verified
@@ -437,10 +991,18 @@ Identity
○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
✓ Direct client-address mode
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
Object storage
○ Check unavailable
Core storage integration has not been implemented yet.
○ Object storage not configured
No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage.
✓ No module requires object storage
0 modules declare a logical storage bucket.
Outbound email
○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
Result: READY WITH WARNINGS
```
@@ -464,6 +1026,51 @@ CLI into a temporary location and structurally compares it against the checked-i
and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade
workflow.
`pnpm modules:enable -- --module <id>` and `pnpm modules:disable -- --module <id>` record which
installed modules this deployment runs. They read `DATABASE_URL`, run the same manifest-only
discovery every other command does (so a module this deployment cannot see is refused), write one
row, and start nothing — the change takes effect at the next `pnpm api:start`. They exist so a module
that breaks startup can be turned off without the API, since the HTTP lifecycle endpoint is served by
the process such a module takes down.
### Getting the first administrator into a fresh installation
A new installation has a login page and nothing to log in to: public registration is disabled by
default and cannot be enabled in production at all. One explicit, operator-invoked command creates
the initial identity and grants it platform ownership
([ADR 0035](docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
```bash
docker compose ... up -d # PostgreSQL and the rest of the stack
pnpm migrations:apply # the schema must already exist
pnpm identity:create-initial-owner -- --email [email protected]
pnpm api:start # then sign in at the Dashboard login page
```
It prompts for the password twice, invisibly, and never accepts one as a command-line argument —
`--password` is refused, because an argument is visible in shell history and in the process list.
For automated deployment it reads the password from standard input instead:
```bash
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]
```
**No environment variable is read for the password and none belongs in `.env`**: it is used once to
create an account, not kept as deployment configuration. The command needs `DATABASE_URL` and
nothing else — no identity secret, no trusted origins, and no public base URL — so the first owner
can be created on a freshly provisioned server before TLS or DNS exist.
It runs **once**. Ownership bootstrap is single-use, so a second run is refused: it creates no
second owner, changes no existing password, and leaves the first owner untouched. It also never
applies migrations — an unmigrated database is reported, not silently fixed. If it created the
identity but failed before granting ownership, running the identical command again adopts that
identity and finishes, leaving its password alone. Creating *additional* users and owners belongs to
user administration, which does not exist yet.
This is not public signup and not a browser setup wizard: there is no `/signup`, `/register`, or
`/setup` route, no unauthenticated bootstrap endpoint, and no setup token. The login page stays
login-only.
`pnpm api:start` builds the workspace, then starts the identity HTTP transport
(`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated
`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for
@@ -491,5 +1098,27 @@ environment.
- [Module development](docs/module-development/README.md)
- [Architecture decisions](docs/architecture/decisions/README.md)
No open-source license has been selected yet. A license should be added only after the project
owner makes that decision.
## License
Ekklesia is licensed under the **GNU Affero General Public License, version 3 or any later version**
(SPDX `AGPL-3.0-or-later`). The full text of version 3 is in [LICENSE](LICENSE). Its source is
available to you under the terms of that license.
> Ekklesia is free software: you can redistribute it and/or modify it under the terms of the GNU
> Affero General Public License as published by the Free Software Foundation, either version 3 of
> the License, or (at your option) any later version.
>
> Ekklesia is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
> the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
> General Public License for more details.
>
> You should have received a copy of the GNU Affero General Public License along with Ekklesia. If
> not, see <https://www.gnu.org/licenses/>.
Section 13 of the license adds an obligation for network use: if you modify Ekklesia and run your
modified version for users who interact with it remotely over a network, your version must offer
those users the opportunity to receive its Corresponding Source.
Official Ekklesia modules, published by the Ekklesia project, are licensed under `AGPL-3.0-or-later`
as well. Each module's own repository declares its license. The module template declares it in its
`package.json`, so a module started from the template begins with the same license.
+7 -2
View File
@@ -1,6 +1,7 @@
{
"name": "@ekklesia/api",
"version": "0.1.0",
"version": "0.2.2",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
"files": [
@@ -21,18 +22,22 @@
"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/shell": "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"
},
+311 -15
View File
@@ -1,6 +1,6 @@
import {
isAuthenticatedPrincipal,
type ModuleRuntimeServices,
type ModuleSecretSource,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import type {
@@ -8,14 +8,38 @@ import type {
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 { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import { Hono } from "hono";
import type {
AnyModuleRuntimeServices,
DiscoveredModule,
ModuleConfigurationLookup,
ModuleConfigurationSettings,
ModuleConfigurationStore,
ModuleLifecycleSnapshot,
ModuleLifecycleStore,
ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
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";
@@ -36,17 +60,100 @@ export interface CreateApiAppOptions {
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
* implicit fallback. */
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
/** 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 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 per-module service lookup the caller's started module runtime used for every
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's
* reference-identical service object, never a fresh facade or executor built per request. */
readonly moduleServices: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
/** 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
@@ -61,6 +168,87 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
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
@@ -127,6 +315,19 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
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 {
@@ -135,13 +336,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
// 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.
const headers = new Headers(response.headers);
headers.set("Cache-Control", "no-store");
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
return browserSafeAuthResponse(c.req.raw, response);
} catch (error) {
return sanitizedErrorResponse(c, error, 500);
}
@@ -163,6 +358,16 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
}
});
// Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below,
// it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules
// own entirely. See `./health-routes.js`.
app.route("/", createHealthRoutes(options.health));
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
// `./dashboard-host-routes.js`.
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
app.route(
"/",
createGrantsRoutes({
@@ -193,9 +398,100 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
}),
);
// 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,
+187 -17
View File
@@ -1,6 +1,10 @@
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts";
import type { Context } from "hono";
import { bodyLimit } from "hono/body-limit";
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
@@ -19,15 +23,20 @@ import { bodyLimit } from "hono/body-limit";
* 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 route declaration is validated against at registration) is the hard
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling:
* nothing in this platform ever declares, or enforces, a larger single request body than this.
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to
* a future dedicated storage design, not to widening this ceiling.
* 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_MAX_BODY_BYTES_CEILING };
export {
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
};
/** The one stable, sanitized public contract for every request-body-size rejection across the
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
@@ -37,12 +46,173 @@ export function requestBodyTooLargeResponse(c: Context): Response {
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
}
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413
* contract rather than redefining `onError` per route. Covers both a declared oversized
* `Content-Length` and an oversized chunked/streamed body with no declared length — see
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */
export function platformBodyLimit(maxSize: number) {
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse });
/**
* 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;
};
}
+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;
});
}
+67 -2
View File
@@ -1,8 +1,8 @@
import {
assertProductionDatabaseTransport,
createDatabaseTransportPolicy,
type DatabaseTransportPolicy,
type DatabaseTransportPolicyInput,
createDatabaseTransportPolicy,
} from "@ekklesia/core/database";
import {
type CreateIdentityConfigOptions,
@@ -10,6 +10,16 @@ import {
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,
@@ -55,12 +65,34 @@ export interface ApiRuntimeConfig {
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;
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. */
@@ -68,6 +100,10 @@ export interface ApiRuntimeConfigInput {
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 {
@@ -142,12 +178,41 @@ export function createApiRuntimeConfig(
// 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;
}
+54 -5
View File
@@ -1,27 +1,68 @@
import type { ModuleDatabase } from "@ekklesia/contracts";
import type { ModuleDatabaseExecutor, ModuleId } 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 { createModuleDatabaseView } from "@ekklesia/core/modules";
import {
createModuleConfigurationView,
createModuleDatabaseExecutor,
createModuleDatabaseView,
createModuleLifecycleView,
type ModuleConfigurationDrizzleDatabase,
type ModuleDatabase,
type ModuleLifecycleDrizzleDatabase,
} from "@ekklesia/core/modules";
export interface ApiDatabaseHandle {
readonly authorization: AuthorizationDrizzleDatabase;
readonly devices: DeviceDrizzleDatabase;
readonly dashboard: DashboardDrizzleDatabase;
readonly identity: IdentityDrizzleDatabase;
readonly module: ModuleDatabase;
/** Builds one ABI-v1 executor attributed to exactly one module, over this handle's shared pool. */
readonly moduleExecutor: (moduleId: ModuleId) => ModuleDatabaseExecutor;
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 three
* independent typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
* `createModuleDatabaseView`). No domain owns or closes the pool in this composition — `close()`
* `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.
@@ -35,7 +76,15 @@ export function createApiDatabase(
return Object.freeze({
authorization: createAuthorizationDrizzleView(pool),
close,
devices: createDeviceDrizzleView(pool),
dashboard: createDashboardDrizzleView(pool),
identity: createIdentityDrizzleView(pool),
module: createModuleDatabaseView(pool),
moduleExecutor: (moduleId: ModuleId) => createModuleDatabaseExecutor(pool, { moduleId }),
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();
}
}
+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);
}
}
+64 -16
View File
@@ -1,9 +1,19 @@
import type {
ModuleId,
ModuleRouteHandlerContext,
ModuleRuntimeServices,
RegisteredModuleHttpRoute,
import {
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleDatabaseExecutor,
type ModuleId,
type ModuleRouteHandlerContext,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import type {
AnyModuleRuntimeServices,
ModuleConfigurationLookup,
ModuleDatabase,
ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
@@ -30,7 +40,28 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
* 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;
readonly services: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
/**
* 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.
*
* Like `services`, 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
@@ -173,9 +204,10 @@ function sanitizeModuleResponse(response: Response): Response {
* 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 `moduleId`, `principal`, `params`, and `services` — never a Hono
* `Context`, authorization store, or configuration; `services.database` is the module's only
* reachable database surface, and is unreachable to anything short of a successfully authorized
* 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).
*
@@ -203,24 +235,40 @@ export function mountModuleHttpRoutes(
if (!options.registry.has(route.permission)) {
throw new ModuleRouteConfigurationError();
}
const services =
"get" in options.services ? options.services.get(route.moduleId) : options.services;
if (!services) {
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 an oversized request never
// reaches authorization work, let alone the handler or the module database.
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes);
// 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({
const context = 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),
services: options.services,
});
realtime: options.realtime.publisherFor(route.moduleId),
services,
}) as ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>;
let response: Response;
try {
response = await route.handler(c.req.raw, context);
response = await (
route.handler as (
request: Request,
context: ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => PromiseLike<Response> | Response
)(c.req.raw, context);
} catch {
return sanitizedModuleHandlerFailure(c);
}
+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);
}
}
+111 -8
View File
@@ -1,6 +1,10 @@
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
import type { ModuleUiPublicationRegistry, PublishableModuleUi } from "@ekklesia/core/modules";
import type {
ModuleUiPublicationRegistry,
PublishableModuleContribution,
PublishableModuleUi,
} from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
import {
@@ -9,7 +13,13 @@ import {
requirePermission,
} from "./authorization-middleware.js";
import { moduleApiBaseUrl } from "./module-http-routes.js";
import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-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
@@ -39,11 +49,18 @@ export interface HostModuleUiDescriptor {
* 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.
@@ -60,12 +77,56 @@ export interface HostModuleUiDescriptor {
* 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
@@ -86,7 +147,9 @@ export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddl
* silently read "denied" during a database outage would blank the whole UI and look like a
* permissions problem.
*/
async function grantedPermissionIds(
/** 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[],
@@ -110,16 +173,53 @@ 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 grantedPermissionIds(evaluator, principal, published.permissions),
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. */
@@ -178,11 +278,14 @@ export function createModuleUiInventoryRoutes(options: CreateModuleUiInventoryRo
// The `authenticated` baseline guarantees this is an authenticated principal.
const principal = getAuthorizedPrincipal(c);
try {
const modules = await Promise.all(
options.moduleUi
.list()
.map((published) => describeModule(options.evaluator, principal, published)),
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);
+109 -39
View File
@@ -77,9 +77,42 @@ export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string {
)}`;
}
/**
* 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 `entrypoints.ui`, or the requested version is not the installed one.
* 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
@@ -122,6 +155,48 @@ function isNotModified(headerValue: string | undefined, etag: string): boolean {
});
}
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
@@ -136,9 +211,9 @@ export interface CreateModuleUiRoutesOptions {
*
* ## The request never touches the filesystem
*
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to
* `registry.resolve` 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,
* `: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
@@ -173,46 +248,41 @@ export interface CreateModuleUiRoutesOptions {
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);
}
// 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,
});
return serveArtifact(c, published);
});
return routes;
+18 -4
View File
@@ -6,6 +6,7 @@ import {
composePlatformPermissionRegistry,
discoverModules,
type DiscoveredModule,
resolveModulesDirectory,
} from "@ekklesia/core/modules";
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
@@ -55,10 +56,18 @@ async function readPackageVersion(packagePath: string): Promise<string> {
}
export interface PrepareStartupModuleInventoryOptions {
/**
* The deployment's `EKKLESIA_MODULES_DIR`, raw, exactly as host configuration read it (ADR 0048).
* A production input, not a test seam: the executable entry point always passes it. Unset means
* the repository's own `modules/`; set means that absolute directory, which must exist. It is
* resolved by Core's one resolver, so an unusable value fails startup with a sanitized
* `ModuleDiscoveryError` before any pool exists.
*/
readonly configuredModulesDirectory?: string | undefined;
/** 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. */
* modules, taking precedence over `configuredModulesDirectory`, which is then never resolved.
* Never set by the executable entry point. 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`
@@ -109,6 +118,11 @@ export async function prepareStartupModuleInventory(
}
const repositoryRoot = resolveRepositoryRoot();
// Chosen before anything is read, so an unusable configured directory fails first and alone.
const modulesDirectory =
options.modulesDirectoryOverride === undefined
? resolveModulesDirectory({ configured: options.configuredModulesDirectory, repositoryRoot })
: { modulesDirectory: options.modulesDirectoryOverride, modulesDirectoryRequired: false };
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
@@ -116,7 +130,7 @@ export async function prepareStartupModuleInventory(
const modules = await discoverModules({
moduleSdkVersion,
modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"),
...modulesDirectory,
platformVersion,
});
+319 -78
View File
@@ -1,29 +1,53 @@
import type { Socket } from "node:net";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { ModuleRuntimeServices } from "@ekklesia/contracts";
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts";
import {
createAuthorizationEvaluator,
createAuthorizationStore,
} from "@ekklesia/core/authorization";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
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 {
type AnyModuleRuntimeServices,
createModuleConfigurationStore,
createModuleLifecycleStore,
createModuleUiPublicationRegistry,
type DiscoveredModule,
type ModuleConfigurationSettings,
type ModuleEligibilityResolution,
type ModuleRuntimeServicesLookup,
resolveModuleEligibility,
type StartedModuleRuntime,
startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules";
import { serve, type ServerType } from "@hono/node-server";
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 ClientAddressConfigInput,
type ClientAddressResolver,
createClientAddressResolver,
wrapFetchWithClientAddress,
} from "./client-address.js";
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js";
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { withDevicePrincipals } from "./device-principal.js";
import { loadDisplayHostAssets } from "./display-host-assets.js";
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
import { prepareStartupModuleInventory } from "./platform-permissions.js";
const connectionTimeoutMs = 10_000;
@@ -119,8 +143,21 @@ 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;
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
* executable entry point. See `prepareStartupModuleInventory`. */
/**
* 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;
/** The deployment's raw `EKKLESIA_MODULES_DIR` (ADR 0048), a production input the executable
* entry point always passes from host configuration. See `prepareStartupModuleInventory`. */
readonly configuredModulesDirectory?: string | undefined;
/** Test-only escape hatch: overrides the directory module discovery scans, taking precedence
* over `configuredModulesDirectory`. 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
@@ -138,6 +175,17 @@ export interface StartApiOptions {
/** 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> {
@@ -149,6 +197,31 @@ async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
}
}
/** 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`). */
@@ -318,14 +391,14 @@ function bindServer(
* (`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. Start the module runtime from that exact same discovered inventory
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the
* same services object every module's `start`/`stop` hook receives.
* 5. Create the Hono application, mounting the started module runtime's published routes and
* passing the exact same services object to every module HTTP handler.
* 6. Bind the Node HTTP server.
* 3. Construct the Core identity runtime, authorization store, and evaluator from that pool.
* 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 the manifest-selected service map.
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
* UI for that same eligible set, and passing the same per-module service map to HTTP handlers.
* 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
@@ -335,16 +408,27 @@ function bindServer(
* 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. 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`.
* 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({
configuredModulesDirectory: options.configuredModulesDirectory,
...(options.modulesDirectoryOverride === undefined
? {}
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
@@ -362,42 +446,155 @@ export async function startApi(
transport: config.databaseTransport,
});
let services: ModuleRuntimeServices;
// 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 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;
}
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>(
eligibility.eligibleModules.map((module) => [
module.manifest.id,
module.manifest.database?.abi === 1
? Object.freeze({ database: databaseHandle.moduleExecutor(module.manifest.id) })
: Object.freeze({ database: databaseHandle.module }),
]),
);
// 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(inventory.modules, services, {
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({
@@ -407,17 +604,66 @@ export async function startApi(
store: authorizationStore,
},
moduleRoutes: moduleRuntime.routes,
moduleRealtime: moduleRuntime.realtime,
moduleServices: services,
// Built from the exact same discovered inventory, and only once the module runtime has
// started — module startup is all-or-nothing, so every module in this list is a running
// module, and a module that failed to load, register, or start never reaches this line at
// all. Modules declaring no `entrypoints.ui` are simply absent from the registry.
moduleUi: createModuleUiPublicationRegistry(inventory.modules),
runtime,
trustedOrigins: config.identity.trustedOrigins,
// 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;
}
@@ -433,6 +679,8 @@ export async function startApi(
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;
}
@@ -442,7 +690,9 @@ export async function startApi(
// 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. Order matters, and is now additionally
// 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
@@ -454,7 +704,12 @@ export async function startApi(
let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => {
if (!shutdownPromise) {
shuttingDown = true;
shutdownPromise = (async () => {
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
// completes: an in-flight module command may still commit and publish its resulting state.
// `moduleRuntime.stop()` deactivates publishers immediately before stop hooks run.
moduleRuntime.realtime.closeSubscriptions();
const drainOutcome = await drainHttpServer(server, drainTimeoutMs);
let moduleStopError: unknown;
try {
@@ -462,6 +717,10 @@ export async function startApi(
} 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,
@@ -505,65 +764,47 @@ export function installShutdownSignalHandlers(
process.on("SIGTERM", handleSignal);
}
function readClientAddressInputFromEnvironment(): ClientAddressConfigInput | undefined {
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
if (mode === undefined) {
// Omitted entirely: `createClientAddressConfig` defaults to direct mode. Reading `header`/
// `trustedProxies` only when `mode` is actually set keeps an operator from accidentally
// half-configuring proxy mode by setting only one of the three variables.
return undefined;
}
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
return {
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
mode,
trustedProxies: trustedProxiesValue
?.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
};
}
function readConfigFromEnvironment(): {
/**
* 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;
readonly modulesDirectory: string | undefined;
} {
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
const trustedOrigins = trustedOriginsValue
?.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
const clientAddress = readClientAddressInputFromEnvironment();
const host = loadHostConfiguration(processEnvironment);
return {
environment: process.env.NODE_ENV === "development" ? "development" : "production",
environment: host.environment,
input: {
...(clientAddress === undefined ? {} : { clientAddress }),
databaseTransport: {
...(process.env.EKKLESIA_DATABASE_CA === undefined
? {}
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
},
databaseUrl: process.env.DATABASE_URL,
host: process.env.EKKLESIA_API_HOST,
identity: {
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
secret: process.env.EKKLESIA_IDENTITY_SECRET,
trustedOrigins,
},
port: process.env.EKKLESIA_API_PORT,
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,
modulesDirectory: host.modulesDirectory,
};
}
export async function main(): Promise<void> {
const { environment, input } = readConfigFromEnvironment();
const { environment, input, moduleSecrets, modulesDirectory } = readConfigFromEnvironment(
process.env,
);
const config = createApiRuntimeConfig(input, { environment });
const running = await startApi(config);
const running = await startApi(config, {
configuredModulesDirectory: modulesDirectory,
moduleSecrets,
});
const address = running.server.address();
const boundPort = address && typeof address === "object" ? address.port : config.port;
File diff suppressed because it is too large Load Diff
+484 -8
View File
@@ -1,24 +1,41 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
moduleIdSchema,
parseEkklesiaUserId,
parsePermissionId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
CORE_PERMISSION_DECLARATIONS,
CORE_PERMISSION_REGISTRY,
createPermissionRegistry,
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { IdentityRuntimeError } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleUiPublicationRegistry,
StagedModuleRealtimeRegistry,
} from "@ekklesia/core/modules";
import { IdentityRuntimeError } from "@ekklesia/core/identity";
import { describe, expect, it, vi } from "vitest";
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
import {
type CreateApiAppAuthorizationOptions,
type CreateApiAppOptions,
createApiApp as createApiAppWithModules,
} from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
@@ -31,9 +48,9 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
};
}
/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
* never real database behavior, so no PostgreSQL is required. */
function fakeModuleServices(): ModuleRuntimeServices {
function fakeModuleServices(): LegacyModuleRuntimeServices {
const database = Object.freeze({
delete: vi.fn(),
execute: vi.fn(),
@@ -54,6 +71,12 @@ function emptyModuleUiPublication(): ModuleUiPublicationRegistry {
return createModuleUiPublicationRegistry([]);
}
/** Most application-wiring cases exercise no modules. Keep that fact explicit in one test helper
* while production composition still requires the realtime registry with no implicit fallback. */
function createApiApp(options: Omit<CreateApiAppOptions, "moduleRealtime">) {
return createApiAppWithModules({ ...options, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY });
}
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
* specific decision or store behavior override the relevant method explicitly. The registry
* defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the
@@ -81,12 +104,35 @@ function fakeAuthorization(overrides: {
return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store };
}
function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
return createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
}
describe("Ekklesia API Hono application", () => {
it("mounts Better Auth exactly under /api/auth/*", async () => {
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -96,15 +142,233 @@ describe("Ekklesia API Hono application", () => {
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
expect(response.status).toBe(200);
expect(await response.text()).toBe("auth-ok");
expect(await response.json()).toEqual({ ok: true });
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
});
it("replaces only a successful email sign-in body without exposing Better Auth's token or user", async () => {
const nativeCookie =
"__Secure-better-auth.session_token=opaque-cookie-value; Path=/; HttpOnly; Secure; SameSite=Lax";
const nativeBody = JSON.stringify({
token: "javascript-visible-session-token",
user: { email: "[email protected]", id: "user_123", name: "Owner" },
});
const nativeResponse = new Response(nativeBody, {
headers: {
"Access-Control-Allow-Origin": trustedOrigin,
"Content-Length": "4096",
"Content-Type": "application/json",
"Set-Cookie": nativeCookie,
"X-Native-Auth-Header": "preserved",
},
status: 201,
statusText: "Signed In",
});
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(nativeResponse),
});
const app = createAuthWiringApp(runtime);
const response = await app.request("/api/auth/sign-in/email", {
headers: { origin: trustedOrigin },
method: "POST",
});
const visibleBody = await response.text();
expect(response.status).toBe(201);
expect(response.statusText).toBe("Signed In");
expect(JSON.parse(visibleBody)).toEqual({ ok: true });
expect(visibleBody).not.toContain("token");
expect(visibleBody).not.toContain("user");
expect(visibleBody).not.toContain("session");
expect(visibleBody).not.toContain("[email protected]");
expect(response.headers.get("set-cookie")).toBe(nativeCookie);
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("content-type")).toBe("application/json; charset=UTF-8");
expect(response.headers.get("content-length")).not.toBe("4096");
expect(response.headers.get("x-native-auth-header")).toBe("preserved");
expect(nativeResponse.bodyUsed).toBe(false);
});
it.each([
"/api/auth/%73ign-in/email",
"/api/auth/sign-in%2Femail",
])("normalizes the router-equivalent encoded sign-in path %s", async (path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(
Response.json({
token: "javascript-visible-session-token",
user: { email: "[email protected]" },
}),
),
});
const app = createAuthWiringApp(runtime);
const response = await app.request(path, { method: "POST" });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
});
it("preserves a failed email sign-in response body and status unchanged", async () => {
const nativeFailure = JSON.stringify({ code: "INVALID_EMAIL_OR_PASSWORD" });
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(
new Response(nativeFailure, {
headers: { "Content-Type": "application/json" },
status: 401,
statusText: "Unauthorized",
}),
),
});
const app = createAuthWiringApp(runtime);
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
expect(response.status).toBe(401);
expect(response.statusText).toBe("Unauthorized");
expect(await response.text()).toBe(nativeFailure);
expect(response.headers.get("cache-control")).toBe("no-store");
});
it.each([
["a GET to the sign-in path", "GET", "/api/auth/sign-in/email"],
["a sign-out POST", "POST", "/api/auth/sign-out"],
["a trailing-slash sign-in POST", "POST", "/api/auth/sign-in/email/"],
])("leaves %s on Better Auth's native success contract", async (_label, method, path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi
.fn()
.mockResolvedValue(new Response("native-auth-body", { status: 200 })),
});
const app = createAuthWiringApp(runtime);
const response = await app.request(path, { method });
expect(response.status).toBe(200);
expect(await response.text()).toBe("native-auth-body");
expect(response.headers.get("cache-control")).toBe("no-store");
});
describe("unused native session-token endpoints", () => {
const disabledRequests = [
["GET", "/api/auth/get-session"],
["POST", "/api/auth/get-session"],
["GET", "/api/auth/list-sessions"],
["POST", "/api/auth/update-session"],
["POST", "/api/auth/change-password"],
] as const;
it.each(
disabledRequests,
)("returns a fixed 404 for %s %s, including query and trailing-slash spellings, without invoking Better Auth", async (method, path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(
Response.json({
session: { token: "javascript-visible-session-token" },
token: "another-session-token",
}),
),
});
const app = createAuthWiringApp(runtime);
for (const requestPath of [path, `${path}?include=session`, `${path}/`]) {
const response = await app.request(requestPath, {
headers: { origin: trustedOrigin },
method,
});
const body = await response.text();
expect(response.status).toBe(404);
expect(JSON.parse(body)).toEqual({ code: "NOT_FOUND" });
expect(body).not.toContain("token");
expect(body).not.toContain("session");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
}
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it.each([
["GET", "/api/auth/%67et-session"],
["GET", "/api/auth/%6Cist-sessions"],
["POST", "/api/auth/%75pdate-session"],
["POST", "/api/auth/change-password%2F"],
["GET", "/api/auth/get-session%2F"],
["GET", "/api/auth%2Fget-session"],
["GET", "/api/auth/get-session%"],
])("does not delegate the encoded or malformed spelling %s %s", async (method, path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi
.fn()
.mockResolvedValue(Response.json({ session: { token: "exposed" } })),
});
const app = createAuthWiringApp(runtime);
const response = await app.request(path, { method });
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it("denies an oversized update-session body before the body limiter or Better Auth reads it", async () => {
const runtime = fakeRuntime();
const app = createAuthWiringApp(runtime);
const response = await app.request("/api/auth/update-session", {
body: "x",
headers: {
"content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1),
"content-type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it.each([
["POST", "/api/auth/list-sessions"],
["GET", "/api/auth/update-session"],
["GET", "/api/auth/change-password"],
["GET", "/api/auth/list-sessions/export"],
])("leaves the neighbouring non-denied request %s %s on the native contract", async (method, path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi
.fn()
.mockResolvedValue(new Response("native-method-response", { status: 200 })),
});
const app = createAuthWiringApp(runtime);
const response = await app.request(path, { method });
expect(response.status).toBe(200);
expect(await response.text()).toBe("native-method-response");
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
});
});
it("does not route requests outside /api/auth/* to Better Auth", async () => {
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -124,6 +388,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -145,6 +415,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -168,6 +444,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -191,6 +473,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -211,6 +499,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -233,6 +527,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -242,7 +542,7 @@ describe("Ekklesia API Hono application", () => {
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
expect(response.status).toBe(200);
expect(await response.text()).toBe("auth-ok");
expect(await response.json()).toEqual({ ok: true });
expect(response.headers.get("cache-control")).toBe("no-store");
});
@@ -252,6 +552,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -273,6 +579,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -301,6 +613,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -330,6 +648,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -364,6 +688,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -380,6 +710,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -398,6 +734,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -415,6 +757,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -430,6 +778,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -452,6 +806,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -477,6 +837,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
runtime: fakeRuntime(),
@@ -505,6 +871,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({
evaluator: { authorize: evaluatorAuthorize },
@@ -524,6 +896,12 @@ describe("Ekklesia API Hono application", () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -538,3 +916,101 @@ describe("Ekklesia API Hono application", () => {
});
});
});
describe("split Dashboard and API origins (ADR 0045)", () => {
const moduleId = moduleIdSchema.parse("announcements");
const permission = parsePermissionId("announcements.slideshow.view");
const endpoint = "/api/v1/module-realtime/announcements/state";
/** The full application with one live channel, a signed-in caller, and an evaluator that allows
* it: the same composition a Dashboard on another origin reaches. */
function realtimeApp() {
const staged = new StagedModuleRealtimeRegistry();
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
registration.realtime.channel({ channel: "state", permission });
registration.commit();
staged.publisherFor(moduleId).publish("state", { mode: "on" } as never);
const moduleRealtime = staged.publish();
const app = createApiAppWithModules({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
moduleRealtime,
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: true }) },
registry: createPermissionRegistry([
...CORE_PERMISSION_DECLARATIONS,
{
baseline: "restricted",
description: "View the announcement slideshow.",
id: permission,
},
]),
}),
runtime: fakeRuntime({
resolvePrincipal: vi
.fn()
.mockResolvedValue(
createAuthenticatedPrincipal(
parseEkklesiaUserId("88888888-8888-4888-8888-888888888888"),
),
),
}),
trustedOrigins: [trustedOrigin],
});
return { app, moduleRealtime };
}
it("lets the Dashboard's origin open a credentialed realtime stream", async () => {
const { app, moduleRealtime } = realtimeApp();
const response = await app.request(endpoint, {
headers: { cookie: "session=1", origin: trustedOrigin },
});
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
expect(response.headers.get("vary")).toContain("Origin");
await response.body?.cancel();
moduleRealtime.closeSubscriptions();
});
it("gives an untrusted origin's realtime request no CORS permission to read the stream", async () => {
const { app, moduleRealtime } = realtimeApp();
const response = await app.request(endpoint, {
headers: { cookie: "session=1", origin: untrustedOrigin },
});
expect(response.headers.get("access-control-allow-origin")).toBeNull();
await response.body?.cancel();
moduleRealtime.closeSubscriptions();
});
it("allows only Content-Type beyond the CORS-safelisted request headers, and exposes none", async () => {
const app = createAuthWiringApp(fakeRuntime());
const response = await app.request("/api/v1/principal", {
headers: {
"access-control-request-headers": "content-type, x-module-custom",
"access-control-request-method": "PATCH",
origin: trustedOrigin,
},
method: "OPTIONS",
});
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("access-control-allow-headers")?.toLowerCase()).toBe(
"content-type",
);
expect(response.headers.get("access-control-expose-headers")).toBeNull();
});
});
+25 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js";
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
const validSecret = "correct-horse-battery-staple-0123456789";
@@ -28,6 +28,9 @@ describe("API runtime configuration", () => {
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"],
@@ -334,3 +337,24 @@ describe("API runtime configuration", () => {
expect(publicText).not.toContain("db.internal.example");
});
});
describe("the API's trusted browser origins (ADR 0045)", () => {
it("adds the API's own origin to the configured ones, as Better Auth does", () => {
const origins = apiTrustedOrigins({
baseUrl: "https://api.example.test",
trustedOrigins: ["https://dash.example.test"],
});
expect(origins).toEqual(["https://dash.example.test", "https://api.example.test"]);
expect(Object.isFrozen(origins)).toBe(true);
});
it("does not duplicate an API origin already listed (the same-origin topology)", () => {
expect(
apiTrustedOrigins({
baseUrl: "https://ekklesia.example.test",
trustedOrigins: ["https://ekklesia.example.test"],
}),
).toEqual(["https://ekklesia.example.test"]);
});
});
@@ -0,0 +1,131 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const readFileMock = vi.hoisted(() => vi.fn());
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
vi.mock("@ekklesia/shell/dashboard-assets", () => ({
dashboardHostAssetUrls: Object.freeze({
html: "file:///surface/browser/index.html",
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
}),
}));
import {
createDashboardHostAssets,
DASHBOARD_API_ORIGIN_PLACEHOLDER,
DashboardHostAssetError,
loadDashboardHostAssets,
renderDashboardPage,
} from "../src/dashboard-host-assets.js";
const API_ORIGIN = "https://api.example.test";
function sha256Etag(text: string): string {
return `"${createHash("sha256").update(text).digest("base64url")}"`;
}
describe("Dashboard host asset loading", () => {
beforeEach(() => readFileMock.mockReset());
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
const script = "export const dashboardHost = true;\n";
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
const assets = await loadDashboardHostAssets(API_ORIGIN);
expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/index.html"), "utf8"],
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
]);
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
expect(assets).toEqual({
html: rendered,
htmlEtag: sha256Etag(rendered),
script,
scriptEtag: sha256Etag(script),
});
expect(Object.isFrozen(assets)).toBe(true);
});
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
readFileMock.mockRejectedValueOnce(
new Error("ENOENT /home/operator/private/surface-build secret-token"),
);
const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error);
expect(readFileMock).toHaveBeenCalledTimes(1);
expect(failure).toBeInstanceOf(DashboardHostAssetError);
expect(failure).toMatchObject({
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
message: "The Dashboard host browser assets are unavailable.",
name: "DashboardHostAssetError",
});
expect((failure as Error).stack).toBe(
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
);
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
expect(publicText).not.toContain(secret);
}
});
it("fails when the script is missing even though the page was read", async () => {
readFileMock
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
.mockRejectedValueOnce(new Error("ENOENT app.js"));
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
DashboardHostAssetError,
);
expect(readFileMock).toHaveBeenCalledTimes(2);
});
it("rejects an empty page or script as an unavailable shell build", () => {
for (const [html, script] of [
["", "export {};"],
["<html></html>", " \n"],
] as const) {
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
}
});
it("fails startup on a page without exactly one empty API-origin element (ADR 0045)", async () => {
for (const html of [
"<!doctype html>",
`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}${DASHBOARD_API_ORIGIN_PLACEHOLDER}`,
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
]) {
readFileMock.mockReset();
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
DashboardHostAssetError,
);
}
});
});
describe("Dashboard page API origin (ADR 0045)", () => {
const page = `<head>${DASHBOARD_API_ORIGIN_PLACEHOLDER}</head>`;
it("writes the API's origin into the one placeholder, in either topology", () => {
expect(renderDashboardPage(page, "https://api.example.test")).toBe(
'<head><meta content="https://api.example.test" name="ekklesia-api-origin" /></head>',
);
expect(renderDashboardPage(page, "http://127.0.0.1:3000")).toBe(
'<head><meta content="http://127.0.0.1:3000" name="ekklesia-api-origin" /></head>',
);
});
it.each([
"https://api.example.test/",
"https://api.example.test/api",
'https://api.example.test"><script>',
"javascript:alert(1)",
"",
])("refuses a value that is not a canonical http(s) origin: %s", (origin) => {
expect(() => renderDashboardPage(page, origin)).toThrow(DashboardHostAssetError);
});
});
@@ -0,0 +1,347 @@
import { createHash } from "node:crypto";
import {
ANONYMOUS_PRINCIPAL,
type ModuleRouteHandler,
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,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} 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(): LegacyModuleRuntimeServices {
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,
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,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} 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: 8,
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 LegacyModuleRuntimeServices,
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.2.0", platform: "^0.2.0" },
entrypoints: {
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
server: "./dist/server/index.js",
},
id: "announcements",
manifestVersion: 8,
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([]);
});
});
+373
View File
@@ -0,0 +1,373 @@
import {
ANONYMOUS_PRINCIPAL,
type ModuleRouteHandler,
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,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} 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(): LegacyModuleRuntimeServices {
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,184 @@
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("hands the API the same modules directory Doctor and the operator commands read", () => {
expect(readConfigFromEnvironment(environment()).modulesDirectory).toBeUndefined();
const env = environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" });
expect(readConfigFromEnvironment(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
expect(loadHostConfiguration(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
});
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();
}
});
});
+255 -21
View File
@@ -8,11 +8,26 @@ const betterAuthMock = vi.hoisted(() => vi.fn());
const drizzleAdapterMock = vi.hoisted(() => vi.fn());
const serveMock = vi.hoisted(() => vi.fn());
const processOnSpy = vi.hoisted(() => vi.fn());
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
// Every startup reads the built Dashboard (ADR 0044), and this file never depends on a built shell,
// so the loader answers a small consistent snapshot unless a test makes it fail.
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
loadDashboardHostAssetsMock.mockImplementation(async () =>
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
);
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
});
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
});
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
// gets the real Hono application; only the one test that deliberately forces an app-construction
// failure overrides this with `mockImplementationOnce`.
@@ -30,6 +45,13 @@ import {
import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiApp } from "../src/app.js";
import { createApiRuntimeConfig } from "../src/config.js";
import { DashboardHostAssetError } from "../src/dashboard-host-assets.js";
import { DisplayHostAssetError } from "../src/display-host-assets.js";
import {
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.js";
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
function validConfig() {
@@ -48,6 +70,21 @@ function validConfig() {
);
}
/**
* The one query behavior every fake pool in this file needs: startup reads the stored module
* enablement configuration (`ModuleLifecycleStore.readConfiguration`) before it imports any module
* entrypoint, and an installation that has never disabled anything genuinely has zero rows. Returning
* an empty result set is therefore the accurate stand-in for a real, migrated, unconfigured database
* — not a way to skip the read. The tests that care about a *specific* configuration drive the real
* resolver directly instead (`core/tests/module-lifecycle.test.ts`) or supply real rows through the
* real store (`core/tests/module-lifecycle-store.integration.ts`).
*/
function emptyQuery() {
return vi
.fn()
.mockResolvedValue({ command: "SELECT", fields: [], oid: 0, rowCount: 0, rows: [] });
}
const temporaryDirectories: string[] = [];
async function createTemporaryModulesDirectory(): Promise<string> {
@@ -67,11 +104,11 @@ async function createFixtureModule(
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 4,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
permissions: [],
entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides,
@@ -134,6 +171,8 @@ interface WorkingFixtureModuleOptions {
* can be exercised deterministically without any sleep. */
readonly stopNeverSettles?: boolean;
readonly dependencies?: Record<string, unknown>;
/** Declares one protected display artifact without importing it. */
readonly withDisplay?: boolean;
}
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
@@ -147,12 +186,43 @@ async function createWorkingFixtureModule(
options: WorkingFixtureModuleOptions = {},
): Promise<void> {
const permissionId = `${id}.ping.read`;
const displayPermission = `${id}.display.view`;
const permissions = [
...(options.withRoute
? [{ baseline: "public" as const, description: "Ping.", id: permissionId }]
: []),
...(options.withDisplay
? [
{
baseline: "restricted" as const,
description: "View the display.",
id: displayPermission,
},
]
: []),
];
await createFixtureModule(modulesDirectory, id, {
permissions: options.withRoute
? [{ baseline: "public", description: "Ping.", id: permissionId }]
: [],
entrypoints: {
server: "./dist/server/index.js",
...(options.withDisplay
? {
display: {
artifact: "./dist/display/index.js",
permission: displayPermission,
},
}
: {}),
},
permissions,
...(options.dependencies ? { dependencies: options.dependencies } : {}),
});
if (options.withDisplay) {
await mkdir(path.join(modulesDirectory, id, "dist/display"), { recursive: true });
await writeFile(
path.join(modulesDirectory, id, "dist/display/index.js"),
'throw new Error("display artifact must never execute during server startup");\n',
);
}
const routeSource = options.withRoute
? `http.route({
method: "GET",
@@ -197,15 +267,16 @@ function fakeDiscoveredModule(
): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" },
id,
manifestVersion: 4,
manifestVersion: 8,
name: `${id} module`,
permissions,
version: "0.1.0",
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
@@ -359,7 +430,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe();
@@ -375,7 +446,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
@@ -392,10 +463,40 @@ describe("Ekklesia API shutdown lifecycle", () => {
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("answers readiness with one bounded ping on the shared pool, and turns it 503 the moment shutdown begins", async () => {
const query = emptyQuery();
poolConstructor.mockImplementation(function fakePool() {
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig());
query.mockClear();
const ready = await running.app.request(HEALTH_READY_PATH);
expect(ready.status).toBe(200);
expect(query).toHaveBeenCalledTimes(1);
// The statement timeout is server-side and scoped to this one transaction, so PostgreSQL itself
// cancels a stalled ping rather than the route merely abandoning it.
expect(query).toHaveBeenCalledWith(
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${READINESS_DATABASE_TIMEOUT_MS}; SELECT 1; COMMIT`,
);
const shutdown = running.shutdown();
const draining = await running.app.request(HEALTH_READY_PATH);
expect(draining.status).toBe(503);
expect(await draining.json()).toEqual({ code: "NOT_READY" });
// Draining is not dead: liveness still answers while in-flight requests finish.
expect((await running.app.request(HEALTH_LIVE_PATH)).status).toBe(200);
expect(query).toHaveBeenCalledTimes(1);
await shutdown;
});
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe();
@@ -411,7 +512,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("closes an already-created database pool when identity construction fails during startup", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockImplementation(() => {
throw new Error("postgresql://admin:[email protected]/x");
@@ -429,7 +530,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
@@ -467,7 +568,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
@@ -520,7 +621,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockFailingServe(new Error("EADDRINUSE"));
@@ -584,7 +685,7 @@ describe("Ekklesia API Node server timeouts", () => {
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
@@ -621,7 +722,7 @@ describe("Ekklesia API Node server timeouts", () => {
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
@@ -673,7 +774,7 @@ describe("Ekklesia API Node server timeouts", () => {
async function startAndCaptureClientErrorHandler() {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { clientErrorHandler } = mockSuccessfulServe();
@@ -891,7 +992,7 @@ describe("Ekklesia API module permission composition at startup", () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
@@ -903,6 +1004,73 @@ describe("Ekklesia API module permission composition at startup", () => {
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("discovers installed modules from the configured modules directory (ADR 0048)", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
await createFixtureModule(modulesDirectory, "incompatible", {
compatibility: { moduleSdk: "^0.1.0", platform: ">=99.0.0" },
});
// The module that fails discovery lives only in the configured directory, so this rejection
// proves discovery read it rather than the repository's own `modules/`.
await expect(
startApi(validConfig(), { configuredModulesDirectory: modulesDirectory }),
).rejects.toBeInstanceOf(ModuleDiscoveryError);
expect(poolConstructor).not.toHaveBeenCalled();
});
it("starts with zero modules from an empty configured modules directory", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
poolConstructor.mockImplementation(function fakePool() {
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig(), { configuredModulesDirectory: modulesDirectory });
expect(poolConstructor).toHaveBeenCalledTimes(1);
await running.shutdown();
});
it.each([
["a relative path", "relative/private-mount-5b2a", "MODULES_DIRECTORY_INVALID"],
["a missing directory", "/nonexistent/private-mount-5b2a", "MODULES_DIRECTORY_UNREADABLE"],
])("fails closed on %s, before any pool exists, naming no path", async (_label, configured, code) => {
let captured: unknown;
try {
await startApi(validConfig(), { configuredModulesDirectory: configured });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(ModuleDiscoveryError);
const issues = (captured as ModuleDiscoveryError).issues;
expect(issues.map((issue) => issue.code)).toEqual([code]);
expect(JSON.stringify(issues)).not.toContain("private-mount-5b2a");
expect((captured as Error).message).not.toContain("private-mount-5b2a");
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("lets the test-only override take precedence over a configured directory", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
poolConstructor.mockImplementation(function fakePool() {
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
// The configured value is unusable; the override wins, so it is never even resolved.
const running = await startApi(validConfig(), {
configuredModulesDirectory: "relative/never-resolved",
modulesDirectoryOverride: modulesDirectory,
});
expect(poolConstructor).toHaveBeenCalledTimes(1);
await running.shutdown();
});
it("does not perform module discovery merely by importing the platform-permissions module", async () => {
const module = await import("../src/platform-permissions.js");
@@ -927,18 +1095,83 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
loadDisplayHostAssetsMock.mockReset();
vi.mocked(createApiApp).mockClear();
});
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
return { poolEnd };
}
it("fails on missing display-host assets before importing module code and still closes startup resources", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "announcements", recorder, {
withDisplay: true,
});
const { poolEnd } = mockPoolAndAuth();
loadDisplayHostAssetsMock.mockRejectedValueOnce(new DisplayHostAssetError());
const failure = await startApi(validConfig(), {
modulesDirectoryOverride: modulesDirectory,
}).catch((error: unknown) => error);
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.",
);
expect(loadDisplayHostAssetsMock).toHaveBeenCalledTimes(1);
expect(recorder.events).toEqual([]);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("fails on missing Dashboard assets before importing module code and still closes startup resources", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
const { poolEnd } = mockPoolAndAuth();
loadDashboardHostAssetsMock.mockRejectedValueOnce(new DashboardHostAssetError());
const failure = await startApi(validConfig(), {
modulesDirectoryOverride: modulesDirectory,
}).catch((error: unknown) => error);
expect(failure).toMatchObject({
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
message: "The Dashboard host browser assets are unavailable.",
name: "DashboardHostAssetError",
});
expect((failure as Error).stack).toBe(
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
);
expect(recorder.events).toEqual([]);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("does not read display-host assets when the eligible set declares no display", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
expect(loadDisplayHostAssetsMock).not.toHaveBeenCalled();
await running.shutdown();
});
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
@@ -1131,6 +1364,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
recorder.events.push("pool:end");
}),
on: vi.fn(),
query: emptyQuery(),
};
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
@@ -1201,9 +1435,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.0",
});
mockPoolAndAuth();
mockSuccessfulServe();
@@ -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,
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 LegacyModuleRuntimeServices,
type ModuleConfigurationRecord,
type ModuleConfigurationStore,
ModuleConfigurationStoreError,
EMPTY_MODULE_REALTIME_REGISTRY,
type ModuleDatabase,
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: 8,
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 LegacyModuleRuntimeServices,
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" });
});
});
+62 -7
View File
@@ -8,11 +8,12 @@ import {
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { moduleDatabaseExecutorModuleId } from "@ekklesia/core/modules";
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 { startApi, type RunningApi } from "../src/server.js";
import { type RunningApi, startApi } from "../src/server.js";
/**
* Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module
@@ -211,15 +212,20 @@ async function applyCoreMigrations(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.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 {
@@ -235,9 +241,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory: fixtureModulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.0",
},
});
// Core migrations aggregate first, exactly once each, and always before any module migration.
@@ -245,6 +251,11 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
"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(
@@ -311,6 +322,27 @@ async function signUp(origin: string, email: string, password: string): Promise<
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 }),
@@ -410,6 +442,11 @@ describe.sequential("live API — module database explicit migration ownership",
const handle = createApiDatabase(database.url);
expect(handle.module).toBeDefined();
const alpha = handle.moduleExecutor("alpha");
const beta = handle.moduleExecutor("beta");
expect(alpha).not.toBe(beta);
expect(moduleDatabaseExecutorModuleId(alpha)).toBe("alpha");
expect(moduleDatabaseExecutorModuleId(beta)).toBe("beta");
await handle.close();
expect(await tableExists(database.url)).toBe(false);
@@ -439,6 +476,10 @@ describe.sequential("live API — module database explicit migration ownership",
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);
}
@@ -495,11 +536,12 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
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 exactly moduleId/principal/params/services, and the handler's database is reference-equal to start's", async () => {
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" });
@@ -509,7 +551,20 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
const readHandlerEvent = [...events]
.reverse()
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
expect(readHandlerEvent?.contextKeys).toEqual(["moduleId", "params", "principal", "services"]);
// 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);
});
@@ -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,
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,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} 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: 8,
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(): LegacyModuleRuntimeServices {
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);
}
});
});
+455 -18
View File
@@ -1,13 +1,11 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRouteHandler,
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
moduleRoutesOverlap,
parsePermissionId,
type PermissionId,
parsePermissionId,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import {
@@ -17,14 +15,26 @@ import {
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { createModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleRuntimeServicesLookup,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import {
canonicalizeVaryHeader,
mergedVaryHeader,
ModuleRouteConfigurationError,
mergedVaryHeader,
} from "../src/module-http-routes.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.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 untrustedOrigin = "https://evil.example.test";
@@ -51,21 +61,24 @@ function fakeStore(): AuthorizationStore {
};
}
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the
/** Defaults `maxBodyBytes` and `bodyMode` for tests that are not themselves exercising the
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
* suite further down overrides it explicitly to prove the contract itself. */
* suite further down overrides them explicitly to prove the contract itself. */
function moduleRoute(
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number },
fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
maxBodyBytes?: number;
},
): RegisteredModuleHttpRoute {
return Object.freeze({ maxBodyBytes: 1024, ...fields });
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
}
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise route mounting,
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
* required. Each `database` method stays a distinct spy so a test can assert none of them were
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
function fakeModuleServices(): ModuleRuntimeServices {
function fakeModuleServices(): LegacyModuleRuntimeServices {
const database = Object.freeze({
delete: vi.fn(),
execute: vi.fn(),
@@ -79,7 +92,7 @@ function fakeModuleServices(): ModuleRuntimeServices {
return Object.freeze({ database });
}
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
function expectDatabaseFakeUntouched(services: LegacyModuleRuntimeServices): void {
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
@@ -95,7 +108,7 @@ function buildModuleApp(options: {
readonly registry: PermissionRegistry;
readonly routes: readonly RegisteredModuleHttpRoute[];
readonly runtime?: EkklesiaIdentityRuntime;
readonly services?: ModuleRuntimeServices;
readonly services?: LegacyModuleRuntimeServices | ModuleRuntimeServicesLookup;
readonly trustedOrigins?: readonly string[];
}) {
const evaluator = options.evaluator ?? {
@@ -106,8 +119,15 @@ function buildModuleApp(options: {
const app = createApiApp({
authorization: { evaluator, registry: options.registry, store: fakeStore() },
moduleRoutes: options.routes,
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime,
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
});
@@ -126,6 +146,36 @@ function permissionRegistry(
}
describe("module HTTP route mounting", () => {
it("selects the owning module's stable service entry for its handler", async () => {
const calendar = fakeModuleServices();
const people = fakeModuleServices();
const services: ModuleRuntimeServicesLookup = new Map([
["calendar", calendar],
["people", people],
]);
let received: unknown;
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
moduleRoute({
handler: (_request, context) => {
received = context.services;
return Response.json({ ok: true });
},
method: "GET",
moduleId: "calendar",
path: "/services",
permission: readPermission,
}),
],
services,
});
expect((await app.request("/api/v1/modules/calendar/services")).status).toBe(200);
expect(received).toBe(calendar);
expect(received).not.toBe(people);
});
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
const { app } = buildModuleApp({
@@ -333,13 +383,20 @@ describe("module route handler adaptation", () => {
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
expect(capturedRequest).toBeInstanceOf(Request);
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
// Exactly moduleId, params, principal, services — never a Hono Context, authorization store,
// or configuration reaches a module handler.
// Exactly config, moduleId, params, principal, services, storage — and nothing else. `config`
// is this module's own frozen configuration snapshot (ADR 0029) and `storage` its own scoped
// object storage (ADR 0030); a Hono Context, the authorization store or evaluator, raw
// PostgreSQL, the storage provider, an S3 endpoint or credential, platform configuration, and
// another module's configuration or storage all remain unreachable, which is what this
// exact-shape assertion exists to keep true.
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
"config",
"moduleId",
"params",
"principal",
"realtime",
"services",
"storage",
]);
expect(capturedContext).toMatchObject({
moduleId: "calendar",
@@ -352,6 +409,14 @@ describe("module route handler adaptation", () => {
expect(Object.isFrozen(capturedContext)).toBe(true);
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
expect(Object.isFrozen(capturedContext?.config)).toBe(true);
// This module declared no configuration, so its snapshot is empty rather than absent — module
// code never has to branch on whether the platform supplied one.
expect(capturedContext?.config.keys()).toEqual([]);
// Likewise storage: this module declared no buckets, so it gets a frozen facade that refuses
// every one rather than `undefined`.
expect(Object.isFrozen(capturedContext?.storage)).toBe(true);
expect(capturedContext?.storage.buckets()).toEqual([]);
});
it("gives static and root routes a frozen, empty params object", async () => {
@@ -474,7 +539,7 @@ describe("module route authorization", () => {
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(handler).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services);
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
});
it("does not invoke the handler when principal resolution fails", async () => {
@@ -603,7 +668,7 @@ describe("module route trusted-origin enforcement", () => {
}
expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services);
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
});
it("allows a trusted-origin state-changing request through to the handler", async () => {
@@ -732,7 +797,7 @@ describe("module route CORS", () => {
expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services);
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
});
it("never reflects an untrusted origin for a module route", async () => {
@@ -1607,3 +1672,375 @@ describe("module route overlap can never reach mounting", () => {
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
});
});
/**
* Explicit, bounded streaming uploads (ADR 0042).
*
* The distinction being proven throughout is *when* the platform knows a body is too large, and what
* it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at
* most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it,
* holding nothing" — and the `413` still wins, even when the handler tried to answer anyway.
*/
describe("module route streamed body limits", () => {
const oneMebibyte = 1024 * 1024;
/** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the
* chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */
function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {
let sent = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (sent >= size) {
controller.close();
return;
}
const next = Math.min(chunkSize, size - sent);
sent += next;
controller.enqueue(new Uint8Array(next).fill(97));
},
});
}
function streamingApp(options: {
readonly handler: ModuleRouteHandler;
readonly maxBodyBytes: number;
readonly evaluator?: AuthorizationEvaluator;
}) {
return buildModuleApp({
...(options.evaluator ? { evaluator: options.evaluator } : {}),
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
bodyMode: "streamed",
handler: options.handler,
maxBodyBytes: options.maxBodyBytes,
method: "POST",
moduleId: "calendar",
path: "/uploads",
permission: writePermission,
}),
],
});
}
/** Counts what the handler actually received, which is the only honest way to tell "streamed" from
* "buffered then handed over". */
async function countingHandler(received: { bytes: number }): Promise<ModuleRouteHandler> {
return async (request) => {
const body = request.body;
if (!body) {
return Response.json({ bytes: 0 });
}
const reader = body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
received.bytes += value.byteLength;
}
return Response.json({ bytes: received.bytes });
};
}
it("accepts a body of exactly the declared limit", async () => {
const received = { bytes: 0 };
const { app } = streamingApp({
handler: await countingHandler(received),
maxBodyBytes: oneMebibyte,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(oneMebibyte),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ bytes: oneMebibyte });
});
it("rejects a declared Content-Length over the limit before the handler runs", async () => {
const received = { bytes: 0 };
const counting = await countingHandler(received);
const handler = vi.fn<ModuleRouteHandler>(counting);
const { app } = streamingApp({ handler, maxBodyBytes: 1024 });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(2048, 16),
duplex: "half",
headers: { "content-length": String(1025), origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
// The cheap rejection: the declared size alone decided it, so no handler work happened and no
// byte of the upload reached module code.
expect(handler).not.toHaveBeenCalled();
expect(received.bytes).toBe(0);
});
it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => {
// A misleading `Content-Length` buys nothing: the running byte count is what bounds the request,
// so a header claiming 8 bytes cannot smuggle a megabyte past the limit.
const received = { bytes: 0 };
const { app } = streamingApp({
handler: await countingHandler(received),
maxBodyBytes: 64,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096, 32),
duplex: "half",
headers: { "content-length": "8", origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
// It stopped promptly rather than draining the whole upload first.
expect(received.bytes).toBeLessThanOrEqual(96);
});
it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => {
// A limit a handler can swallow is not a limit.
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
try {
await request.arrayBuffer();
} catch {
// Deliberately ignored, exactly as a careless module might.
}
return Response.json({ ok: "handler won" });
});
const { app } = streamingApp({ handler, maxBodyBytes: 64 });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096, 32),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(handler).toHaveBeenCalledTimes(1);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
it("never buffers the body: the handler sees the first bytes before the last are sent", async () => {
let released: (() => void) | undefined;
const finished = new Promise<void>((resolve) => {
released = resolve;
});
let firstChunkSeen = false;
let secondChunkQueued = false;
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
if (!secondChunkQueued) {
secondChunkQueued = true;
controller.enqueue(new Uint8Array(16).fill(97));
return;
}
// Only produce the rest once the handler has demonstrably consumed the first chunk. A
// buffering platform would deadlock here rather than reach the assertion below.
await finished;
controller.close();
},
});
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
const reader = (request.body as ReadableStream<Uint8Array>).getReader();
const first = await reader.read();
firstChunkSeen = !first.done;
released?.();
for (;;) {
const next = await reader.read();
if (next.done) {
break;
}
}
return Response.json({ ok: true });
});
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(firstChunkSeen).toBe(true);
expect(response.status).toBe(200);
});
it("releases an aborted upload without taking the platform down", async () => {
const aborted = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(16).fill(97));
controller.error(new Error("client went away"));
},
});
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
await request.arrayBuffer();
return Response.json({ ok: true });
});
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: aborted,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
// An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit.
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
});
it("releases a body the handler never read", async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
cancel() {
cancelled = true;
},
pull(controller) {
controller.enqueue(new Uint8Array(16).fill(97));
},
});
const { app } = streamingApp({
handler: async () => Response.json({ ok: true }),
maxBodyBytes: oneMebibyte,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(200);
expect(cancelled).toBe(true);
});
it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: false }),
};
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
evaluator,
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
routes: [
moduleRoute({
bodyMode: "streamed",
handler,
maxBodyBytes: 64,
method: "POST",
moduleId: "calendar",
path: "/uploads",
permission: writePermission,
}),
],
});
const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", {
body: "a".repeat(65),
headers: { "content-length": "65", origin: trustedOrigin },
method: "POST",
});
expect(declaredOversize.status).toBe(413);
expect(evaluator.authorize).not.toHaveBeenCalled();
// A within-limit body from a denied caller is refused by authorization, and the handler — the
// only thing that ever reads a byte in streamed mode — never runs.
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(32, 8),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(handler).not.toHaveBeenCalled();
});
it("still rejects an untrusted Origin before any body handling", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096),
duplex: "half",
headers: { origin: untrustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(handler).not.toHaveBeenCalled();
});
it("keeps every rejection sanitized, naming no limit, size, or body content", async () => {
const { app } = streamingApp({
handler: async () => Response.json({ ok: true }),
maxBodyBytes: 64,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: "secret-payload-".repeat(64),
headers: { "content-length": String(15 * 64), origin: trustedOrigin },
method: "POST",
});
const body = await response.text();
expect(response.status).toBe(413);
expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}');
expect(body).not.toContain("secret-payload");
expect(body).not.toContain("64");
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("still materializes a buffered body before the handler runs", async () => {
// The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the
// body is already there and already within its declared allowance.
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
const text = await request.text();
return Response.json({ length: text.length });
});
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
bodyMode: "buffered",
handler,
maxBodyBytes: 128,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const withinLimit = await app.request("/api/v1/modules/calendar/events", {
body: chunkedBody(128, 16),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(withinLimit.status).toBe(200);
expect(await withinLimit.json()).toEqual({ length: 128 });
const overLimit = await app.request("/api/v1/modules/calendar/events", {
body: chunkedBody(129, 16),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(overLimit.status).toBe(413);
// A buffered route's handler never sees an over-limit request at all.
expect(handler).toHaveBeenCalledTimes(1);
});
});
@@ -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,
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 LegacyModuleRuntimeServices,
type ModuleDatabase,
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: 8,
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 LegacyModuleRuntimeServices,
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 ModuleRealtimePublisher,
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 LegacyModuleRuntimeServices,
type ModuleDatabase,
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: 8,
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(): LegacyModuleRuntimeServices {
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);
});
});
+406 -32
View File
@@ -5,8 +5,6 @@ import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -18,17 +16,36 @@ import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModules,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host";
import {
loadModuleUi,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
} from "@ekklesia/module-ui-host";
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
import { Window } from "happy-dom";
import {
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:
@@ -73,16 +90,24 @@ afterEach(async () => {
* 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): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, "example");
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-example",
name: `@test/module-${id}`,
type: "module",
version: "0.1.0",
}),
@@ -91,16 +116,36 @@ async function createFixtureModule(modulesDirectory: string): Promise<void> {
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" },
id: "example",
manifestVersion: 4,
name: "Example module",
entrypoints: {
server: "./dist/server/index.js",
ui: "./dist/ui/index.js",
...(withDisplay
? {
display: {
artifact: "./dist/display/index.js",
permission: `${id}.display.view`,
},
}
: {}),
},
id,
manifestVersion: 8,
name,
permissions: [
{
baseline: "restricted",
description: "Read example records.",
id: "example.records.read",
id: `${id}.records.read`,
},
...(withDisplay
? [
{
baseline: "restricted",
description: "View the example display.",
id: `${id}.display.view`,
},
]
: []),
],
version: "0.1.0",
}),
@@ -108,13 +153,13 @@ async function createFixtureModule(modulesDirectory: string): Promise<void> {
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default {
id: "example",
id: ${JSON.stringify(id)},
version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/records",
permission: "example.records.read",
permission: ${JSON.stringify(`${id}.records.read`)},
maxBodyBytes: 0,
async handler() {
return Response.json([{ id: "a" }, { id: "b" }]);
@@ -128,10 +173,11 @@ async function createFixtureModule(modulesDirectory: string): Promise<void> {
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default {
id: "example",
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)},
version: "0.1.0",
async mount(container, context) {
if (!context.permissions.has("example.records.read")) {
if (!context.permissions.has(${JSON.stringify(`${id}.records.read`)})) {
container.textContent = context.moduleId + ":denied";
return () => { container.textContent = ""; };
}
@@ -143,11 +189,33 @@ async function createFixtureModule(modulesDirectory: string): Promise<void> {
},
};\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 {
@@ -163,14 +231,17 @@ interface Harness {
async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
temporaryDirectories.push(modulesDirectory);
await createFixtureModule(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({
const services: LegacyModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
});
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
@@ -180,7 +251,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
const granted = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
platformOwners: { isPlatformOwner: async () => false },
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
@@ -196,11 +267,31 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
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: createModuleUiPublicationRegistry(modules),
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"],
});
@@ -232,10 +323,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
return `data:text/javascript;base64,${bytes.toString("base64")}`;
}
/** The loader hands the container straight to the module and never inspects it, so a plain sink is
* enough and no DOM implementation is needed in this Node-environment suite. */
function containerSink(): { textContent: string } {
return { textContent: "" };
/**
* 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"];
@@ -250,17 +366,22 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
// 1. The shell's own bootstrap: principal, then inventory.
const bootstrap = await bootstrapHost(harness.config);
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: viewerId });
expect(bootstrap.modules).toHaveLength(1);
// 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.modules[0];
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",
});
@@ -283,13 +404,92 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
});
// 5. The module rendered, having reached its own backend through the host.
expect(container.textContent).toBe("example:200:2");
expect(mountedText(container)).toBe("example:200:2");
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
// 7. And it tears down cleanly.
await mounted.unmount();
expect(container.textContent).toBe("");
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();
@@ -304,7 +504,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
});
try {
const bootstrap = await bootstrapHost(harness.config);
const descriptor = bootstrap.modules[0];
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
if (!descriptor) {
throw new Error("the inventory returned no module");
}
@@ -336,7 +536,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
});
try {
const bootstrap = await bootstrapHost(harness.config);
const descriptor = bootstrap.modules[0];
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
if (!descriptor) {
throw new Error("the inventory returned no module");
}
@@ -357,20 +557,194 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
});
// Hiding a control is a usability improvement: the module simply did not ask.
expect(container.textContent).toBe("example:denied");
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.modules).toEqual([]);
expect(bootstrap.navigation).toEqual({ groups: [], ungrouped: [] });
} finally {
await harness.stop();
}
@@ -383,7 +757,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
});
try {
const bootstrap = await bootstrapHost(harness.config);
const descriptor = bootstrap.modules[0];
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
if (!descriptor) {
throw new Error("the inventory returned no module");
}
+139 -5
View File
@@ -5,8 +5,6 @@ import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -20,13 +18,26 @@ import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} 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");
@@ -50,6 +61,12 @@ interface ModuleFixture {
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> {
@@ -73,8 +90,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
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: 4,
manifestVersion: 8,
name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({
...permission,
@@ -94,6 +119,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
`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 {
@@ -158,7 +192,7 @@ async function install(options: InstallOptions): Promise<Installation> {
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const services: ModuleRuntimeServices = Object.freeze({
const services: LegacyModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
});
const permissionRegistry = composePlatformPermissionRegistry(modules);
@@ -166,8 +200,18 @@ async function install(options: InstallOptions): Promise<Installation> {
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],
});
@@ -202,16 +246,19 @@ describe("module UI inventory — what it lists", () => {
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 both URLs with the server's own canonical helpers", async () => {
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(),
@@ -226,6 +273,9 @@ describe("module UI inventory — what it lists", () => {
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 () => {
@@ -294,8 +344,11 @@ describe("module UI inventory — what it lists", () => {
// 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",
]);
@@ -498,3 +551,84 @@ describe("module UI inventory — it is not a module management API", () => {
}
});
});
/**
* The inventory is how a consuming module learns what was contributed to its own capabilities
* (ADR 0041). It is the *only* way: a module never queries a registry and never builds a URL.
*/
describe("module UI inventory — contributions", () => {
const signage = {
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
id: "signage",
permissions: [],
withUi: true,
};
const announcements = (capabilityRange = "^1.0.0") => ({
contributions: [
{ capability: "signage.slide-renderer", capabilityRange, localName: "signage-slide" },
],
id: "announcements",
permissions: [],
withUi: true,
});
it("gives the providing module the contribution and its server-produced URL", async () => {
const { app } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
const consumer = modules.find((module) => module.id === "signage");
expect(consumer?.contributions).toEqual([
{
capability: "signage.slide-renderer",
id: "announcements.signage-slide",
url: "/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
version: "1.0.0",
},
]);
});
it("gives the contributing module nothing: it provides no capability that was targeted", async () => {
const { app } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.find((module) => module.id === "announcements")?.contributions).toEqual([]);
});
it("withholds a contribution whose provider is outside its declared range", async () => {
const { app } = await install({
fixtures: [signage, announcements("^2.0.0")],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.find((module) => module.id === "signage")?.contributions).toEqual([]);
});
it("carries no filesystem path or domain vocabulary in a contribution descriptor", async () => {
const { app, modulesDirectory } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { body, modules } = await inventoryOf(app);
const consumer = modules.find((module) => module.id === "signage");
const contributions = (consumer?.contributions ?? []) as ReadonlyArray<Record<string, unknown>>;
expect(body).not.toContain(modulesDirectory);
expect(Object.keys(contributions[0] ?? {}).sort()).toEqual([
"capability",
"id",
"url",
"version",
]);
});
});
+390 -24
View File
@@ -1,32 +1,42 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRuntimeServices,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
CORE_PERMISSION_REGISTRY,
} from "@ekklesia/core/authorization";
import { ANONYMOUS_PRINCIPAL } from "@ekklesia/contracts";
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host";
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
import { Window } from "happy-dom";
import type { Hono } from "hono";
import { 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 {
MODULE_UI_ASSET_PREFIX,
MODULE_UI_CACHE_CONTROL,
MODULE_UI_CONTENT_TYPE,
moduleContributionArtifactUrl,
moduleDisplayUiArtifactUrl,
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";
@@ -52,6 +62,7 @@ async function createModulesDirectory(): Promise<string> {
* sequence whose exact publication and loader-compatibility can be asserted. */
function artifactSource(id: string, version: string): string {
return `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)},
version: ${JSON.stringify(version)},
mount(container, context) {
@@ -70,14 +81,26 @@ interface ModuleFixture {
/** Written to `dist/ui/index.js` and declared as `entrypoints.ui`. Omit for a backend-only
* module, whose manifest then declares no UI entrypoint at all. */
readonly ui?: string;
/** Written to a distinct artifact and declared as the protected display entrypoint. */
readonly display?: string;
/** Extra package-relative files written alongside the artifact — a source map, a sibling bundle
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
* files that genuinely exist on disk rather than against hypothetical ones. */
readonly extraFiles?: Readonly<Record<string, string>>;
/** Capabilities this module provides — what another module's contributions target. */
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
/** Browser renderers this module contributes to another module's capability. */
readonly contributions?: readonly {
readonly localName: string;
readonly capability: string;
readonly capabilityRange: string;
readonly source: string;
}[];
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const version = fixture.version ?? "0.1.0";
const displayPermission = `${fixture.id}.display.view`;
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile(
@@ -96,10 +119,36 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
entrypoints: {
server: "./dist/server/index.js",
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
...(fixture.display === undefined
? {}
: {
display: {
artifact: "./dist/display/index.js",
permission: displayPermission,
},
}),
},
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: 4,
manifestVersion: 8,
name: `${fixture.id} module`,
permissions:
fixture.display === undefined
? []
: [
{
baseline: "restricted",
description: `View ${fixture.id}'s display`,
id: displayPermission,
},
],
version,
}),
);
@@ -111,6 +160,20 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
}
for (const contribution of fixture.contributions ?? []) {
const contributionDirectory = path.join(
moduleDirectory,
"dist/contributions",
contribution.localName,
);
await mkdir(contributionDirectory, { recursive: true });
await writeFile(path.join(contributionDirectory, "index.js"), contribution.source);
await writeFile(path.join(contributionDirectory, "index.js.map"), '{"sources":["secret"]}');
}
if (fixture.display !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
}
for (const [relativePath, contents] of Object.entries(fixture.extraFiles ?? {})) {
const target = path.join(moduleDirectory, relativePath);
await mkdir(path.dirname(target), { recursive: true });
@@ -131,7 +194,10 @@ interface Installation {
* artifact path is hand-built, so these tests exercise the actual security boundary rather than a
* reconstruction of it.
*/
async function install(fixtures: readonly ModuleFixture[]): Promise<Installation> {
async function install(
fixtures: readonly ModuleFixture[],
publicationModuleIds?: readonly string[],
): Promise<Installation> {
const modulesDirectory = await createModulesDirectory();
for (const fixture of fixtures) {
await createModule(modulesDirectory, fixture);
@@ -141,9 +207,13 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
modulesDirectory,
platformVersion: "0.1.0",
});
const publicationModules =
publicationModuleIds === undefined
? modules
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
const database = Object.freeze({}) as unknown as ModuleDatabase;
const services: ModuleRuntimeServices = Object.freeze({ database });
const services: LegacyModuleRuntimeServices = Object.freeze({ database });
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
// Anonymous, and every authorization decision denied: whatever this route does, it does it
@@ -164,10 +234,29 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: { evaluator, registry: CORE_PERMISSION_REGISTRY, store },
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
...(publicationModules.some((module) => module.entrypoints.display !== undefined)
? {
displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="display-root"></div>',
"export {};\n",
"export {};\n",
),
}
: {}),
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleUi: createModuleUiPublicationRegistry(publicationModules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime,
trustedOrigins: [trustedOrigin],
});
@@ -329,6 +418,155 @@ describe("module UI artifact publication — happy path", () => {
});
});
describe("module display UI artifact publication", () => {
it("serves the declared display bytes anonymously at the exact role-specific URL", async () => {
const source = `${artifactSource("announcements", "1.4.2")}// display\n`;
const { app } = await install([{ display: source, id: "announcements", version: "1.4.2" }]);
const url = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" });
expect(url).toBe(`${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2/display`);
const response = await app.request(url);
expect(response.status).toBe(200);
expect(await response.text()).toBe(source);
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
const etag = response.headers.get("etag") as string;
expect(etag).toMatch(/^"[A-Za-z0-9_-]+"$/u);
const revalidated = await app.request(url, { headers: { "if-none-match": etag } });
expect(revalidated.status).toBe(304);
expect(await revalidated.text()).toBe("");
expect(revalidated.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
expect(revalidated.headers.get("etag")).toBe(etag);
});
it("keeps display-only, primary-only, both, and neither roles strictly separate", async () => {
const primary = `${artifactSource("signage", "0.1.0")}// primary\n`;
const display = `${artifactSource("signage", "0.1.0")}// display\n`;
const { app } = await install([
{ display: `${display}// announcements\n`, id: "announcements" },
{ id: "calendar", ui: `${primary}// calendar\n` },
{ display, id: "signage", ui: primary },
{ id: "backend" },
]);
const primaryUrl = (moduleId: string) => moduleUiArtifactUrl({ moduleId, version: "0.1.0" });
const displayUrl = (moduleId: string) =>
moduleDisplayUiArtifactUrl({ moduleId, version: "0.1.0" });
expect((await app.request(primaryUrl("announcements"))).status).toBe(404);
expect((await app.request(displayUrl("announcements"))).status).toBe(200);
expect((await app.request(primaryUrl("calendar"))).status).toBe(200);
expect((await app.request(displayUrl("calendar"))).status).toBe(404);
expect(await (await app.request(primaryUrl("signage"))).text()).toBe(primary);
expect(await (await app.request(displayUrl("signage"))).text()).toBe(display);
expect((await app.request(primaryUrl("backend"))).status).toBe(404);
expect((await app.request(displayUrl("backend"))).status).toBe(404);
});
it("refuses wrong versions with the same sanitized response as an undeclared display", async () => {
const { app } = await install([
{
display: `${artifactSource("announcements", "1.4.2")}// display\n`,
id: "announcements",
version: "1.4.2",
},
{ id: "calendar", ui: artifactSource("calendar", "0.1.0") },
]);
const responses = await Promise.all(
[
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "latest" }),
moduleDisplayUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }),
moduleDisplayUiArtifactUrl({ moduleId: "missing", version: "1.4.2" }),
].map(async (url) => {
const response = await app.request(url);
return { body: await response.text(), status: response.status };
}),
);
expect(responses).toEqual([
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
]);
});
it("never turns the display URL into a package directory or generic role selector", async () => {
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
const { app } = await install([
{
display,
extraFiles: {
".env": "DISPLAY_SECRET=do-not-serve\n",
"dist/display/index.js.map": "display source map secret",
"dist/display/internal-chunk.js": "export const internal = 'secret';\n",
},
id: "announcements",
},
]);
const exact = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
for (const url of [
`${exact}.map`,
`${exact}/index.js.map`,
`${exact}/internal-chunk.js`,
`${exact}/../../.env`,
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/dashboard`,
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/arbitrary-role`,
]) {
const response = await app.request(url);
expect(response.status, url).toBe(404);
const body = await response.text();
expect(body, url).not.toContain("secret");
expect(body, url).not.toContain("mount");
}
});
it("cannot publish a blocked module omitted from Core's eligible registry input", async () => {
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
const { app } = await install(
[
{ display, id: "announcements" },
{ display: `${display}// signage\n`, id: "signage" },
],
["signage"],
);
expect(
(
await app.request(
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
)
).status,
).toBe(404);
expect(
(await app.request(moduleDisplayUiArtifactUrl({ moduleId: "signage", version: "0.1.0" })))
.status,
).toBe(200);
});
it("reports the same fixed 503 when the validated display artifact disappears", async () => {
const { app, modulesDirectory } = await install([
{
display: `${artifactSource("announcements", "0.1.0")}// display\n`,
id: "announcements",
},
]);
await rm(path.join(modulesDirectory, "announcements/dist/display/index.js"));
const response = await app.request(
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" });
expect(response.headers.get("cache-control")).toBe("no-store");
});
});
describe("module UI artifact publication — module identity", () => {
async function expectNotPublished(app: Hono, url: string): Promise<void> {
const response = await app.request(url);
@@ -658,6 +896,12 @@ describe("module UI artifact URL generation", () => {
});
});
interface HostContainer {
readonly children: { readonly length: number };
querySelector(selectors: string): HostContainer | null;
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
}
describe("module UI artifact publication — the generic loader consumes what is published", () => {
/**
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
@@ -676,24 +920,40 @@ describe("module UI artifact publication — the generic loader consumes what is
}
/**
* The loader forwards the container untouched to the module's own `mount` and never inspects it,
* so a plain sink is enough and no DOM implementation is required. The container's type is read
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles
* against Node libs only — naming a DOM type here would be the same browser/server confusion this
* route exists to keep apart.
* A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
* root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
* rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
* DOM type here would be the same browser/server confusion this route exists to keep apart.
*/
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
function containerSink(): { textContent: string } {
return { textContent: "" };
/**
* `happy-dom` used as a library rather than as this suite's environment: the loader now builds a
* real isolated root inside the container, and installing a DOM environment here would replace
* the module runner's resolution for a server-side suite that dynamically imports real module
* entrypoints.
*/
function containerSink(): HostContainer {
return new Window().document.createElement("div") as unknown as HostContainer;
}
/** What the mounted artifact rendered, read through its open isolated root. */
function mountedText(container: HostContainer): string {
const shadowHost = container.querySelector("[data-ekklesia-module-ui]");
return (
shadowHost?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""
);
}
function hostContext() {
return {
api: { request: async () => new Response(null, { status: 200 }) },
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId: "announcements",
permissions: { has: () => false },
principal: { kind: "anonymous" as const },
realtime: { subscribe: () => ({ close: () => undefined }) },
};
}
@@ -710,9 +970,9 @@ describe("module UI artifact publication — the generic loader consumes what is
source: { moduleId: "announcements", url, version: "1.4.2" },
});
expect(container.textContent).toBe("announcements:mounted");
expect(mountedText(container)).toBe("announcements:mounted");
await mounted.unmount();
expect(container.textContent).toBe("");
expect(container.children).toHaveLength(0);
});
it("still rejects the published artifact when the host expects a different identity", async () => {
@@ -730,3 +990,109 @@ describe("module UI artifact publication — the generic loader consumes what is
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
});
});
/**
* Contribution artifact publication (ADR 0041), on exactly the same terms as every other declared
* artifact: three lookup keys, one exact registry match, one already-validated canonical path.
*/
describe("module contribution artifact publication", () => {
const signage = {
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
id: "signage",
};
const contributionSource = "export default { contributed: true };\n";
const announcements = (capabilityRange = "^1.0.0") => ({
contributions: [
{
capability: "signage.slide-renderer",
capabilityRange,
localName: "signage-slide",
source: contributionSource,
},
],
id: "announcements",
});
const contributionUrl = moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "0.1.0" },
"announcements.signage-slide",
);
it("serves the declared contribution artifact byte for byte", async () => {
const { app } = await install([signage, announcements()]);
const response = await app.request(contributionUrl);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(await response.text()).toBe(contributionSource);
});
it("builds a URL that cannot select a neighbouring file", () => {
expect(contributionUrl).toBe(
"/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
);
// The contribution id is one segment's worth of text, always — a value that needed encoding
// simply fails to resolve.
expect(
moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "0.1.0" },
"../../../etc/passwd",
),
).toBe("/api/v1/module-ui/announcements/0.1.0/contributions/..%2F..%2F..%2Fetc%2Fpasswd");
});
it.each([
["an unknown contribution id", "announcements.nope"],
["another module's contribution id", "signage.slide-renderer"],
["a path traversal", "../../../etc/passwd"],
["the artifact's source map", "announcements.signage-slide.map"],
])("refuses %s with the same indistinguishable 404", async (_label, contributionId) => {
const { app } = await install([signage, announcements()]);
const response = await app.request(
moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "0.1.0" },
contributionId,
),
);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
});
it("refuses a version the platform does not have installed", async () => {
const { app } = await install([signage, announcements()]);
const response = await app.request(
moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "9.9.9" },
"announcements.signage-slide",
),
);
expect(response.status).toBe(404);
});
it("publishes nothing when no installed module provides the target capability", async () => {
const { app } = await install([announcements()]);
expect((await app.request(contributionUrl)).status).toBe(404);
});
it("publishes nothing when the provider is outside the declared compatible range", async () => {
const { app } = await install([signage, announcements("^2.0.0")]);
expect((await app.request(contributionUrl)).status).toBe(404);
});
it("keeps the sibling source map and the module's own directory unreachable", async () => {
const { app } = await install([signage, announcements()]);
for (const suffix of [".map", "/index.js.map", "/../ui/index.js"]) {
const response = await app.request(`${contributionUrl}${suffix}`);
expect(response.status).toBe(404);
}
});
});
@@ -0,0 +1,13 @@
import {
createDashboardHostAssets,
type DashboardHostAssets,
} from "../../src/dashboard-host-assets.js";
/** A small, internally consistent Dashboard snapshot for compositions whose tests do not exercise
* the Dashboard host. Its own behavior is tested in `../dashboard-host-routes.test.ts`. */
export function testDashboardHostAssets(): DashboardHostAssets {
return createDashboardHostAssets(
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
"export const dashboardHost = true;\n",
);
}
@@ -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,
};
}
+8 -1
View File
@@ -2,7 +2,14 @@ import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: ["./src/identity/schema.ts", "./src/authorization/schema.ts"],
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",
@@ -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
);
+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
+30
View File
@@ -19,6 +19,36 @@
"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"
}
}
]
}
+35
View File
@@ -22,6 +22,41 @@
"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
}
]
}
+28 -1
View File
@@ -1,6 +1,7 @@
{
"name": "@ekklesia/core",
"version": "0.1.0",
"version": "0.2.2",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
"files": [
@@ -15,6 +16,14 @@
"types": "./dist/modules/index.d.ts",
"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"
@@ -34,6 +43,18 @@
"./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": {
@@ -45,17 +66,23 @@
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@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": {
"@biomejs/biome": "2.5.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",
"vitest": "4.1.10"
},
+41 -7
View File
@@ -5,11 +5,16 @@ import {
type AuthorizationScope,
type EkklesiaPrincipal,
isAuthenticatedPrincipal,
isDevicePrincipal,
permissionBaselineSchema,
safeParseAuthorizationScope,
safeParsePermissionId,
} from "@ekklesia/contracts";
import type { AuthorizationGrantPort, AuthorizationPlatformOwnerPort } from "./ports.js";
import type {
AuthorizationDevicePort,
AuthorizationGrantPort,
AuthorizationPlatformOwnerPort,
} from "./ports.js";
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
@@ -54,6 +59,10 @@ export interface AuthorizationEvaluator {
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;
@@ -100,17 +109,14 @@ export function createAuthorizationEvaluator(
}
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;
}
// 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;
}
let scope: AuthorizationScope | undefined;
if (request.scope !== undefined) {
const parsedScope = safeParseAuthorizationScope(request.scope);
@@ -120,6 +126,34 @@ export function createAuthorizationEvaluator(
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;
+1
View File
@@ -1,6 +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 });
});
}
+91
View File
@@ -98,6 +98,77 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
*/
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
@@ -116,6 +187,26 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
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,
}),
]);
/**
+20 -1
View File
@@ -1,4 +1,9 @@
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
import type {
AuthorizationScope,
EkklesiaDeviceId,
EkklesiaUserId,
PermissionId,
} from "@ekklesia/contracts";
export interface ActiveGrantQuery {
readonly userId: EkklesiaUserId;
@@ -26,3 +31,17 @@ export interface AuthorizationGrantPort {
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>;
}
+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;
});
}
+14 -21
View File
@@ -4,35 +4,28 @@ import {
type DatabaseTransportPolicy,
createDatabaseTransportPolicy,
} from "../database/transport.js";
import { loadHostConfiguration } from "../host/index.js";
/**
* The one place every Core-owned executable reads its database transport policy from the process
* environment, so `pnpm doctor`, `pnpm migrations:plan`, `pnpm migrations:apply`, and
* `pnpm authorization:bootstrap-owner` cannot drift from each other or from `apps/api`.
* The database transport policy for the small Core-owned executables — `pnpm migrations:plan`,
* `pnpm migrations:apply`, `pnpm modules:enable`, and the owner-bootstrap commands — resolved from
* the process environment.
*
* Environment reading lives here, in the executable layer, deliberately: the reusable Core
* database/migration constructors keep taking explicit typed inputs and never touch `process.env`.
* `NODE_ENV` is interpreted exactly as `apps/api` interprets it — only the literal string
* `"development"` relaxes anything, so an unset or misspelled value validates as production.
* The mapping itself belongs to `loadHostConfiguration`, which `startApi` and `pnpm doctor` also
* call, so `NODE_ENV` and the two TLS variables have exactly one interpretation across every entry
* point. Environment *reading* still happens here, in the executable layer: the reusable Core
* database and migration constructors keep taking explicit typed inputs and never touch
* `process.env`.
*/
export function readDatabaseTransportFromEnvironment(): {
readonly environment: DatabaseTransportEnvironment;
readonly transport: DatabaseTransportPolicy;
} {
const environment: DatabaseTransportEnvironment =
process.env.NODE_ENV === "development" ? "development" : "production";
const transport = createDatabaseTransportPolicy(
{
...(process.env.EKKLESIA_DATABASE_CA === undefined
? {}
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
},
{ environment },
);
return { environment, transport };
const host = loadHostConfiguration(process.env);
const transport = createDatabaseTransportPolicy(host.databaseTransport, {
environment: host.environment,
});
return { environment: host.environment, transport };
}
/**
+72 -53
View File
@@ -2,11 +2,13 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
diagnoseEkklesia,
type DoctorOptions,
diagnoseEkklesia,
doctorExitCode,
formatDoctorReport,
} from "../diagnostics/index.js";
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
interface ModuleSdkPackageDocument {
readonly version?: unknown;
@@ -18,6 +20,20 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
};
}
/**
* Everything Doctor needs that comes from the repository itself rather than the deployment
* environment, resolved by the caller so the environment mapping below stays pure and testable.
*/
export interface DoctorCommandMetadata {
readonly moduleSdkVersion: string;
readonly platformVersion: string;
/** Where the platform is installed: Core's migrations live beneath it, and so does the
* development default modules directory. Installed modules may live anywhere else. */
readonly repositoryRoot: string;
readonly runtimeRange: string;
readonly runtimeVersion: string;
}
async function readJson(pathname: string): Promise<unknown> {
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
}
@@ -36,40 +52,57 @@ function requiredString(value: unknown): string {
return value;
}
function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] {
const secret = process.env.EKKLESIA_IDENTITY_SECRET;
const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL;
const publicSignupEnabled = process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED;
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
const trustedOrigins = trustedOriginsValue
? trustedOriginsValue
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0)
: undefined;
return {
...(baseUrl ? { baseUrl } : {}),
environment: process.env.NODE_ENV === "development" ? "development" : "production",
...(publicSignupEnabled ? { publicSignupEnabled } : {}),
...(secret ? { secret } : {}),
...(trustedOrigins ? { trustedOrigins } : {}),
};
/**
* The modules directory exactly as `startApi` would choose it. An unusable configured value is not
* thrown here, because Doctor must still produce a report: it is handed on as a required directory
* for discovery to refuse, which reports it as a sanitized module-discovery failure.
*/
function doctorModulesDirectory(
configured: string | undefined,
repositoryRoot: string,
): ModulesDirectorySelection {
try {
return resolveModulesDirectory({ configured, repositoryRoot });
} catch {
return { modulesDirectory: configured ?? "", modulesDirectoryRequired: true };
}
}
function readClientAddressFromEnvironment(): DoctorOptions["clientAddress"] {
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
if (mode === undefined) {
return undefined;
}
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
/**
* Turns one deployment environment into Doctor's inputs.
*
* Every infrastructure value comes from `loadHostConfiguration` — the same function `apps/api` calls
* with the same environment — so `pnpm doctor` and `startApi` cannot disagree about what this
* deployment is configured to use. Doctor previously read its own subset here and silently omitted
* object storage and module secrets entirely, which made it report a correctly configured
* installation as having neither.
*
* Everything is handed over **raw**. Doctor validates it and reports a sanitized code, so a
* malformed value appears in the report — which is the whole reason an operator runs this command —
* instead of throwing before a report exists.
*/
export function createDoctorOptions(
environment: HostEnvironment,
metadata: DoctorCommandMetadata,
): DoctorOptions {
const host = loadHostConfiguration(environment);
return {
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
mode,
trustedProxies: trustedProxiesValue
?.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
clientAddress: host.clientAddress,
coreMigrationsDirectory: path.join(metadata.repositoryRoot, "core", "drizzle"),
databaseEnvironment: host.environment,
databaseTransport: host.databaseTransport,
databaseUrl: host.databaseUrl,
identityConfiguration: { ...host.identity, environment: host.environment },
mailConfiguration: host.mail,
moduleDiscovery: {
moduleSdkVersion: metadata.moduleSdkVersion,
...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot),
platformVersion: metadata.platformVersion,
},
moduleSecrets: host.moduleSecrets,
runtimeRange: metadata.runtimeRange,
runtimeVersion: metadata.runtimeVersion,
storageConfiguration: host.storage,
};
}
@@ -81,29 +114,15 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
const rootPackage = rootPackageDocument(rootValue);
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
const databaseEnvironment =
process.env.NODE_ENV === "development" ? ("development" as const) : ("production" as const);
const report = await diagnoseEkklesia({
clientAddress: readClientAddressFromEnvironment(),
databaseEnvironment,
databaseTransport: {
...(process.env.EKKLESIA_DATABASE_CA === undefined
? {}
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
},
databaseUrl: process.env.DATABASE_URL,
identityConfiguration: readIdentityConfigurationFromEnvironment(),
moduleDiscovery: {
const report = await diagnoseEkklesia(
createDoctorOptions(process.env, {
moduleSdkVersion: requiredString(moduleSdkPackage.version),
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion: requiredString(rootPackage.version),
},
runtimeRange: requiredString(rootPackage.engines?.node),
runtimeVersion: process.versions.node,
});
repositoryRoot,
runtimeRange: requiredString(rootPackage.engines?.node),
runtimeVersion: process.versions.node,
}),
);
console.log(formatDoctorReport(report).join("\n"));
return doctorExitCode(report);
+10 -1
View File
@@ -1,7 +1,9 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { HostEnvironment } from "../host/index.js";
import { discoverAllMigrations } from "../migrations/index.js";
import type { ModuleMigrationAggregate } from "../modules/index.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
interface PackageVersion {
readonly version?: unknown;
@@ -15,9 +17,16 @@ async function readPackageVersion(packagePath: string): Promise<string> {
return packageData.version;
}
/**
* Core's migrations come from the platform (`<repositoryRoot>/core/drizzle`); installed modules'
* come from the configured modules directory, wherever it is (ADR 0048). Neither is ever derived
* from the other.
*/
export async function discoverRepositoryMigrations(
repositoryRoot = process.cwd(),
environment: HostEnvironment = process.env,
): Promise<ModuleMigrationAggregate> {
const modulesDirectory = resolveCommandModulesDirectory(environment, repositoryRoot);
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
@@ -27,7 +36,7 @@ export async function discoverRepositoryMigrations(
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
moduleDiscovery: {
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
...modulesDirectory,
platformVersion,
},
});
+21 -3
View File
@@ -1,5 +1,9 @@
import type { ModuleMigrationAggregate } from "../modules/index.js";
import type { MigrationApplyResult, MigrationPlanResult } from "../migrations/index.js";
import type {
MigrationApplyResult,
MigrationPlanResult,
RetainedMigrationHistory,
} from "../migrations/index.js";
function plural(count: number, singular: string): string {
return count === 1 ? singular : `${singular}s`;
@@ -28,10 +32,19 @@ export function formatMigrationValidationReport(
return Object.freeze(lines);
}
/** An absent module's applied history (ADR 0048): kept, reported, and never a reason to stop. */
function retainedLines(retained: readonly RetainedMigrationHistory[]): readonly string[] {
return retained.map(
(module) =>
`- [retained] ${module.moduleId}: ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`,
);
}
export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly string[] {
const readyCount = plan.pendingCount - plan.approvalRequiredCount;
const retainedSummary = plan.retained.length === 0 ? "" : `, ${plan.retained.length} retained`;
const lines = [
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked):`,
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked${retainedSummary}):`,
];
for (const entry of plan.entries) {
@@ -40,6 +53,7 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
if (plan.entries.length === 0) {
lines.push("- No installed module migrations.");
}
lines.push(...retainedLines(plan.retained));
lines.push(
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
);
@@ -49,7 +63,10 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
if (result.noOp) {
return Object.freeze(["Migration apply completed: database is already current (no changes)."]);
return Object.freeze([
"Migration apply completed: database is already current (no changes).",
...retainedLines(result.retained),
]);
}
const lines = [
@@ -58,5 +75,6 @@ export function formatMigrationApplyReport(result: MigrationApplyResult): readon
for (const migration of result.applied) {
lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
}
lines.push(...retainedLines(result.retained));
return Object.freeze(lines);
}
@@ -0,0 +1,17 @@
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
/**
* The modules directory an operator command discovers, read through the same host configuration
* `startApi` and Doctor use and resolved by the one Core resolver (ADR 0048). Throws the resolver's
* sanitized `ModuleDiscoveryError` for an unusable value, which every command already reports.
*/
export function resolveCommandModulesDirectory(
environment: HostEnvironment,
repositoryRoot: string,
): ModulesDirectorySelection {
return resolveModulesDirectory({
configured: loadHostConfiguration(environment).modulesDirectory,
repositoryRoot,
});
}
+184
View File
@@ -0,0 +1,184 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import {
createModuleLifecycleDatabase,
createModuleLifecycleStore,
discoverModules,
isDependencyBlockedDetail,
isStorageBlockedDetail,
ModuleDiscoveryError,
ModuleLifecycleStoreError,
resolveModuleEligibility,
} from "../modules/index.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
/**
* Enables or disables one installed module, out of band from the running API.
*
* This exists because module startup is deliberately all-or-nothing (ADR 0015, ADR 0020, preserved
* by ADR 0028): a module whose `start` hook throws takes API startup down with it, and the HTTP
* lifecycle API that could otherwise turn it off is served by that same process. Without an
* out-of-band path, the only recovery from one bad module would be editing the database by hand.
* This command is that path — the same shape as `pnpm authorization:bootstrap-owner`: explicit,
* operator-invoked, never HTTP-exposed, and never inferred.
*
* It writes configuration and nothing else. It starts nothing, stops nothing, imports no module
* server entrypoint, runs no lifecycle hook, and applies no migration. The change takes effect on
* the next API start.
*/
class ModuleLifecycleCliError extends Error {
constructor(message: string) {
super(message);
this.name = "ModuleLifecycleCliError";
this.stack = `${this.name}: ${this.message}`;
}
}
const usage =
"Usage: pnpm modules:enable -- --module <module-id> | pnpm modules:disable -- --module <module-id>";
interface ParsedArgs {
readonly enabled: boolean;
readonly moduleId: string;
}
/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts`: this command
* takes one named flag plus one mode flag, so a general-purpose argument dependency is not
* warranted. `--enable` and `--disable` are mutually exclusive and one is required, so the intent
* is always stated rather than defaulted. */
function parseArgs(argv: readonly string[]): ParsedArgs {
let moduleId: string | undefined;
let enabled: boolean | undefined;
for (let index = 0; index < argv.length; index += 1) {
const flag = argv[index];
if (flag === "--module") {
moduleId = argv[index + 1];
index += 1;
} else if (flag === "--enable") {
if (enabled === false) {
throw new ModuleLifecycleCliError(usage);
}
enabled = true;
} else if (flag === "--disable") {
if (enabled === true) {
throw new ModuleLifecycleCliError(usage);
}
enabled = false;
}
}
if (moduleId === undefined || enabled === undefined) {
throw new ModuleLifecycleCliError(usage);
}
return { enabled, moduleId };
}
interface PackageVersion {
readonly version?: unknown;
}
async function readPackageVersion(packagePath: string): Promise<string> {
const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion;
if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) {
throw new ModuleLifecycleCliError("The repository package metadata could not be read.");
}
return packageData.version;
}
async function main(): Promise<void> {
const { enabled, moduleId } = parseArgs(process.argv.slice(2));
const repositoryRoot = process.cwd();
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
]);
// The same rule the HTTP API enforces: a module this deployment cannot currently see may not be
// configured. Manifest-only, exactly like `pnpm modules:validate` — no module server entrypoint
// is imported and no lifecycle hook runs, so a module that crashes during `start` is still fully
// configurable here, which is the case this command exists for.
const discovered = await discoverModules({
moduleSdkVersion,
...resolveCommandModulesDirectory(process.env, repositoryRoot),
platformVersion,
});
if (!discovered.some((module) => module.manifest.id === moduleId)) {
throw new ModuleLifecycleCliError(
`No installed module declares the id "${moduleId}". No changes were made.`,
);
}
// 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 ModuleLifecycleCliError("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 } = createModuleLifecycleDatabase(databaseUrl, {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
transport,
});
try {
const store = createModuleLifecycleStore(db);
await store.setEnabled({ enabled, moduleId });
const resolution = resolveModuleEligibility(discovered, await store.readConfiguration());
console.log(
`Module "${moduleId}" is now configured ${enabled ? "enabled" : "disabled"}. Restart the API to apply it.`,
);
for (const entry of resolution.entries) {
const blocked = entry.blocked;
if (!blocked) {
continue;
}
// Names the dependency, the configuration key, or the logical storage bucket — never a
// configuration value, an endpoint, or a credential.
const cause = isDependencyBlockedDetail(blocked)
? blocked.dependency.moduleId
: isStorageBlockedDetail(blocked)
? blocked.storageBucketId
: blocked.configurationKey;
console.log(`- "${entry.moduleId}" will not start: ${blocked.reason} (${cause}).`);
}
} finally {
await close().catch(() => undefined);
}
}
function reportCliError(error: unknown): void {
if (error instanceof ModuleLifecycleCliError) {
console.error(error.message);
return;
}
if (error instanceof ModuleLifecycleStoreError) {
console.error(`[${error.code}] ${error.message}`);
return;
}
if (error instanceof ModuleDiscoveryError) {
console.error(error.message);
for (const issue of error.issues) {
const issuePath = issue.path ? ` (${issue.path})` : "";
console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`);
}
return;
}
console.error("The module enablement change failed.");
}
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) => {
reportCliError(error);
process.exitCode = 1;
});
}
+24 -1
View File
@@ -3,11 +3,13 @@ import path from "node:path";
import { PermissionRegistryError } from "../authorization/index.js";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModuleSchemas,
ModuleDiscoveryError,
ModulePermissionCompositionError,
ModuleSchemaDiscoveryError,
} from "../modules/index.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
interface PackageVersion {
version?: unknown;
@@ -29,7 +31,7 @@ async function main(): Promise<void> {
]);
const schemaAggregate = await discoverModuleSchemas({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
...resolveCommandModulesDirectory(process.env, repositoryRoot),
platformVersion,
});
const modules = schemaAggregate.discoveredModules;
@@ -45,11 +47,32 @@ async function main(): Promise<void> {
return;
}
// Built from the same registry the API publishes from, so this command can never disagree with
// what a running platform would actually serve. A contribution whose target capability nobody
// provides — or whose provider moved outside its declared range — is otherwise silent: the
// manifest is valid, the artifact exists, and nothing renders (ADR 0041).
const publication = createModuleUiPublicationRegistry(modules);
const compatibleContributions = new Set(
publication
.listContributions()
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
);
console.log(
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
);
for (const discoveredModule of modules) {
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
for (const contribution of discoveredModule.contributions) {
const resolved = compatibleContributions.has(
`${discoveredModule.manifest.id}\u0000${contribution.id}`,
);
console.log(
` contributes ${contribution.id}@${contribution.version} to ${contribution.capability} ${contribution.capabilityRange} — ${
resolved ? "compatible provider installed" : "no compatible provider installed"
}`,
);
}
}
}
+175
View File
@@ -0,0 +1,175 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
type ModulePackagePlatform,
ModulePackageVerificationError,
verifyModulePackage,
} from "../modules/index.js";
/**
* `verify-module-package` — verifies an Ekklesia Module Package v1 archive against this platform
* (ADR 0049), and never installs it.
*
* node core/dist/cli/verify-module-package.js <archive>
* [--sha256 <hex> | --sha256-file <file>] the archive's expected SHA-256
* [--extract-to <new directory>] write the verified module directory there
*
* Run from the platform's root (`/opt/ekklesia` in the image), like every operator command: the
* platform's own versions are read from there. `--extract-to` writes only after every check passed,
* into a directory that must not exist yet; it is the staging step of an installation, not the
* installation itself.
*/
class VerifyArgumentsError extends Error {}
const USAGE =
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
export interface VerifyModulePackageArguments {
readonly archivePath: string;
readonly expectedSha256?: string;
readonly sha256File?: string;
readonly extractTo?: string;
}
function normalizedDigest(value: string): string {
const digest = value.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/u.test(digest)) {
throw new VerifyArgumentsError("The SHA-256 must be 64 hexadecimal characters.");
}
return digest;
}
export function parseVerifyModulePackageArguments(
argv: readonly string[],
): VerifyModulePackageArguments {
const positional: string[] = [];
const options = new Map<string, string>();
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index] ?? "";
if (argument.startsWith("--")) {
if (!["--sha256", "--sha256-file", "--extract-to"].includes(argument)) {
throw new VerifyArgumentsError(USAGE);
}
const value = argv[index + 1];
if (value === undefined || value.startsWith("--") || options.has(argument)) {
throw new VerifyArgumentsError(USAGE);
}
options.set(argument, value);
index += 1;
} else {
positional.push(argument);
}
}
const archivePath = positional[0];
if (positional.length !== 1 || archivePath === undefined || archivePath === "") {
throw new VerifyArgumentsError(USAGE);
}
if (options.has("--sha256") && options.has("--sha256-file")) {
throw new VerifyArgumentsError(USAGE);
}
const sha256 = options.get("--sha256");
const sha256File = options.get("--sha256-file");
const extractTo = options.get("--extract-to");
return {
archivePath,
...(sha256 === undefined ? {} : { expectedSha256: normalizedDigest(sha256) }),
...(sha256File === undefined ? {} : { sha256File }),
...(extractTo === undefined ? {} : { extractTo }),
};
}
/** The `sha256sum` format: one line, the digest, whitespace, and a file name that is never trusted. */
export function parseSha256File(contents: string): string {
const lines = contents.split("\n").filter((line) => line.trim() !== "");
const match = lines.length === 1 ? /^([0-9a-fA-F]{64})\s+\*?\S/u.exec(lines[0] ?? "") : null;
if (!match?.[1]) {
throw new VerifyArgumentsError("The SHA-256 file must hold one sha256sum line.");
}
return match[1].toLowerCase();
}
async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
}
function requiredString(value: unknown): string {
if (typeof value !== "string" || value === "") {
throw new VerifyArgumentsError("The platform's own package metadata could not be read.");
}
return value;
}
/** What this platform is, read from where it is installed. The transitional database runtime is
* the Drizzle version Core itself depends on (ADR 0049). */
export async function readModulePackagePlatform(
repositoryRoot: string,
): Promise<ModulePackagePlatform> {
const [root, sdk, core] = await Promise.all([
readJson(path.join(repositoryRoot, "package.json")),
readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json")),
readJson(path.join(repositoryRoot, "core", "package.json")),
]);
const dependencies = core.dependencies as Record<string, unknown> | undefined;
return {
databaseRuntime: { "drizzle-orm": requiredString(dependencies?.["drizzle-orm"]) },
moduleSdkVersion: requiredString(sdk.version),
platformVersion: requiredString(root.version),
};
}
async function main(): Promise<void> {
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
let expectedSha256 = parsed.expectedSha256;
if (parsed.sha256File !== undefined) {
let contents: string;
try {
contents = await readFile(parsed.sha256File, "utf8");
} catch {
throw new VerifyArgumentsError("The SHA-256 file could not be read.");
}
expectedSha256 = parseSha256File(contents);
}
const platform = await readModulePackagePlatform(process.cwd()).catch(() => {
throw new VerifyArgumentsError("The platform's own package metadata could not be read.");
});
const result = await verifyModulePackage({
archivePath: parsed.archivePath,
platform,
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
});
console.log(
`Module package verified: ${result.moduleId}@${result.version} (target ${result.target}), ${result.fileCount} files, sha256 ${result.archiveSha256}.`,
);
if (expectedSha256 === undefined) {
console.log("The archive's SHA-256 was not checked: no expected digest was given.");
}
if (parsed.extractTo !== undefined) {
console.log("The verified module directory was extracted. Nothing was installed.");
}
}
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) => {
if (error instanceof ModulePackageVerificationError) {
console.error(`[${error.code}] ${error.message}`);
if (error.issueCodes.length > 0) {
console.error(`Module validation issues: ${error.issueCodes.join(", ")}`);
}
} else if (error instanceof VerifyArgumentsError) {
console.error(error.message);
} else {
console.error("Module package verification failed unexpectedly.");
}
process.exitCode = 1;
});
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./navigation.js";
export * from "./schema.js";
export * from "./store.js";
+156
View File
@@ -0,0 +1,156 @@
import type { DashboardConfiguration } from "./store.js";
/**
* One module as the Dashboard needs to present it: its identity, its administrator-visible name,
* and nothing about where it lives on disk.
*
* `name` is the module's own `manifest.name` — its product identity, which every manifest has
* already required since version 1. The Dashboard renders it rather than the module id, and a module
* declaring it is *not* the module declaring its navigation: a name answers "what is this product
* called", while a group answers "where does this church want it", and only the second is
* configuration an administrator owns.
*/
export interface DashboardNavigationModule {
readonly id: string;
readonly name: string;
}
export interface DashboardNavigationGroupView<Module extends DashboardNavigationModule> {
/** The stable key, unchanged by a rename. Callers use it to address the group, never to display. */
readonly id: string;
readonly label: string;
readonly order: number;
readonly modules: readonly Module[];
}
export interface DashboardNavigationView<Module extends DashboardNavigationModule> {
readonly groups: readonly DashboardNavigationGroupView<Module>[];
/**
* Modules that belong to no group: those an administrator deliberately placed outside every group,
* and those never configured at all — a newly installed module, most importantly. Both render
* here, which is what guarantees a new module is visible the moment it is discovered, with no
* initialization write, no manual database edit, and no migration.
*/
readonly ungrouped: readonly Module[];
}
/** Placed modules first, in the administrator's order; never-configured modules after them, by id.
* A newcomer therefore appears without interrupting an ordering someone chose, and the module id is
* always the final tie-break, so two modules sharing a `sortOrder` still order identically on every
* request and in every process. */
interface SortKey {
readonly configured: 0 | 1;
readonly order: number;
readonly id: string;
}
function compareSortKeys(left: SortKey, right: SortKey): number {
if (left.configured !== right.configured) {
return left.configured - right.configured;
}
if (left.order !== right.order) {
return left.order - right.order;
}
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
}
/**
* Resolves stored Dashboard configuration against the modules that are actually mountable right now.
*
* Pure and synchronous: no database, no filesystem, no clock, no module code. It is given the stored
* arrangement and the current module set and returns what to render, so every rule below is
* directly testable without PostgreSQL.
*
* ## The rules, exactly
*
* - **A module with no placement is ungrouped and visible.** Nothing is written to make that true.
* Installing a module is enough to see it; an administrator moves it afterwards if they want to.
* - **A stored placement for a module that is not installed renders nothing, and is retained.** The
* store keeps the row, so uninstalling and reinstalling a module restores its position rather than
* silently resetting it. A dead navigation entry never appears.
* - **A placement naming a group that no longer exists cannot occur** — the schema's
* `on delete set null` makes the module ungrouped when its group is deleted — but if one somehow
* did, it is treated as ungrouped rather than dropped, so no module can be made invisible by a
* configuration inconsistency.
* - **Empty groups are kept.** A group an administrator created and has not filled yet, or whose
* only module is temporarily uninstalled, still renders; hiding it would look like the group had
* been deleted.
* - **Ordering is total.** Groups by `(order, id)`; modules by `(configured, order, id)`. Colliding
* order values never produce a nondeterministic result.
*
* ## What this function structurally cannot do
*
* It never reads a manifest, a dependency, a capability, an entrypoint, or a permission, and it
* returns no edge between two modules. Two modules sharing a group are exactly as related as two
* modules that do not: grouping is a rendering decision computed here, downstream of everything that
* decides what a module *is* (ADR 0027). Nothing upstream — discovery, dependency ordering, runtime
* startup, authorization — ever calls this.
*/
export function resolveDashboardNavigation<Module extends DashboardNavigationModule>(
configuration: DashboardConfiguration,
mountableModules: readonly Module[],
): DashboardNavigationView<Module> {
const placementsByModuleId = new Map(
configuration.placements.map((placement) => [placement.moduleId, placement] as const),
);
const groupIds = new Set(configuration.groups.map((group) => group.id));
const modulesByGroupId = new Map<string, Array<{ key: SortKey; module: Module }>>();
const ungrouped: Array<{ key: SortKey; module: Module }> = [];
for (const module of mountableModules) {
const placement = placementsByModuleId.get(module.id);
const key: SortKey = {
configured: placement ? 0 : 1,
id: module.id,
order: placement?.sortOrder ?? 0,
};
const groupId =
placement?.groupId !== undefined &&
placement.groupId !== null &&
groupIds.has(placement.groupId)
? placement.groupId
: undefined;
if (groupId === undefined) {
ungrouped.push({ key, module });
continue;
}
const bucket = modulesByGroupId.get(groupId);
if (bucket) {
bucket.push({ key, module });
} else {
modulesByGroupId.set(groupId, [{ key, module }]);
}
}
const sortModules = (entries: Array<{ key: SortKey; module: Module }>): readonly Module[] =>
Object.freeze(
[...entries]
.sort((left, right) => compareSortKeys(left.key, right.key))
.map((entry) => entry.module),
);
const groups = [...configuration.groups]
.sort((left, right) =>
left.sortOrder !== right.sortOrder
? left.sortOrder - right.sortOrder
: left.id < right.id
? -1
: left.id > right.id
? 1
: 0,
)
.map((group) =>
Object.freeze({
id: group.id,
label: group.label,
modules: sortModules(modulesByGroupId.get(group.id) ?? []),
order: group.sortOrder,
}),
);
return Object.freeze({
groups: Object.freeze(groups),
ungrouped: sortModules(ungrouped),
});
}
+95
View File
@@ -0,0 +1,95 @@
import { sql } from "drizzle-orm";
import { check, index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
/**
* Core-owned storage for how the Ekklesia Dashboard *presents* installed modules.
*
* This is presentation configuration, and it is deliberately the only thing here. A group owns no
* tables, no migrations, no routes, and no lifecycle; it never participates in dependency
* resolution, discovery order, or module startup. **Modules stay flat** (ADR 0021): there is no
* parent module, no child module, no owning module, and nothing in this schema is ever read by
* anything that decides what a module may do or when it starts. Moving a module between groups
* changes a row here and nothing else.
*
* Core stores this; it renders nothing. `surfaces/shell` decides what the arrangement looks like.
*
* Platform-global, because Ekklesia has no organization or tenant model to scope it to. When one
* exists, these two tables gain a scope column; inventing a second notion of organization here to
* anticipate it would be worse than adding a column later.
*
* Table and index names are explicit and namespaced exactly like `core/src/authorization/schema.ts`,
* so ownership is unambiguous inside the shared public PostgreSQL schema.
*/
/**
* A Dashboard navigation group — "Media", "Planning", "Sunday Production".
*
* `id` is an **opaque, stable key**, never displayed and never re-derived from `label`. Renaming a
* group changes only `label`, so every placement pointing at it survives untouched; that invariant
* is what keeps a rename from silently re-organizing the Dashboard. A slug is used rather than a
* generated UUID purely so the configuration API and its tests stay legible — it carries no meaning
* the platform reads.
*
* There is no parent group column. Nesting is deliberately out of scope, and leaving the shape flat
* is what keeps it out.
*/
export const ekklesiaCoreDashboardGroup = pgTable(
"ekklesia_core_dashboard_group",
{
id: text("id").primaryKey(),
label: text("label").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("ekklesia_core_dashboard_group_order_idx").on(table.sortOrder, table.id),
// Defense in depth beneath the application-layer identifier and label contracts, which never
// produce either of these: an empty id would be an unaddressable group, and an empty or
// whitespace-only label would render as a blank heading no administrator could find again.
check("ekklesia_core_dashboard_group_id_check", sql`length(${table.id}) > 0`),
check("ekklesia_core_dashboard_group_label_check", sql`length(btrim(${table.label})) > 0`),
],
);
/**
* Where one module sits in the Dashboard. One row per module at most — the primary key enforces it
* — so a module can never be placed in two groups, which is the database-level expression of "one
* level of grouping, and no module ownership graph."
*
* `group_id` is nullable and that is a real state, not a missing value: **ungrouped is a placement**,
* chosen by an administrator who ordered a module outside every group. A module with no row at all
* is a different state — never configured — and both render in the same ungrouped area (see
* `../dashboard/navigation.js` for the exact ordering rule).
*
* `on delete set null` is what makes group deletion safe: deleting a group cannot leave a dangling
* reference, and its modules become ungrouped rather than disappearing. That is enforced by the
* database, not by application cleanup that could be skipped.
*
* `module_id` deliberately carries **no foreign key to any module**, because modules are not rows —
* they are packages discovered on disk at startup. A placement for a module that is not installed
* is therefore normal and is retained: uninstalling and reinstalling a module keeps its position
* rather than silently resetting it. Nothing renders such a row (see `resolveDashboardNavigation`).
*/
export const ekklesiaCoreDashboardPlacement = pgTable(
"ekklesia_core_dashboard_placement",
{
moduleId: text("module_id").primaryKey(),
groupId: text("group_id").references(() => ekklesiaCoreDashboardGroup.id, {
onDelete: "set null",
}),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("ekklesia_core_dashboard_placement_group_idx").on(table.groupId, table.sortOrder),
check("ekklesia_core_dashboard_placement_module_check", sql`length(${table.moduleId}) > 0`),
],
);
/** Explicit map passed to Drizzle, mirroring `authorizationSchema`'s explicit-mapping style. */
export const dashboardSchema = {
dashboardGroup: ekklesiaCoreDashboardGroup,
dashboardPlacement: ekklesiaCoreDashboardPlacement,
} as const;
+314
View File
@@ -0,0 +1,314 @@
import { asc, eq } from "drizzle-orm";
import type { Pool } from "pg";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import {
dashboardSchema,
ekklesiaCoreDashboardGroup,
ekklesiaCoreDashboardPlacement,
} from "./schema.js";
export type DashboardDrizzleDatabase = NodePgDatabase<typeof dashboardSchema>;
/**
* A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`:
* it never constructs, registers an error listener on, or closes a pool. The caller owns the pool
* once, however many typed views it creates over it.
*/
export function createDashboardDrizzleView(pool: Pool): DashboardDrizzleDatabase {
return drizzle(pool, { schema: dashboardSchema });
}
/** Bounds every stored ordering value. Wide enough that an administrator never runs out of room
* between two entries, small enough that a client cannot store a value that surprises a later
* integer column. Enforced identically by the store and by the HTTP surface, so there is one bound
* rather than two that could drift. */
export const DASHBOARD_SORT_ORDER_MIN = 0;
export const DASHBOARD_SORT_ORDER_MAX = 1_000_000;
/** The label an administrator sees. Bounded like every other operator-facing string in the
* platform; `moduleManifestSchema.name` uses the same ceiling. */
export const DASHBOARD_GROUP_LABEL_MAX_LENGTH = 120;
/** Lowercase kebab-case, matching `moduleIdSchema`'s grammar so the platform has one identifier
* shape rather than two. Opaque: never displayed, never parsed for meaning, never re-derived from a
* label. */
const GROUP_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const GROUP_ID_MAX_LENGTH = 64;
/** The one identifier grammar for both a group id and a placement's module id, exported so the HTTP
* boundary rejects malformed input with a request error *before* reaching the store rather than
* relying on mapping a store failure back to a status code — and so there is one grammar rather
* than two that could drift. */
export function isValidDashboardIdentifier(value: unknown): value is string {
return (
typeof value === "string" && value.length <= GROUP_ID_MAX_LENGTH && GROUP_ID_PATTERN.test(value)
);
}
/** The one label rule, exported for the same reason. Returns the trimmed value the store would
* persist, or `undefined` when the label is empty, whitespace-only, or too long. */
export function normalizeDashboardGroupLabel(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length === 0 || trimmed.length > DASHBOARD_GROUP_LABEL_MAX_LENGTH
? undefined
: trimmed;
}
export type DashboardStoreErrorCode =
| "DASHBOARD_GROUP_ID_INVALID"
| "DASHBOARD_GROUP_LABEL_INVALID"
| "DASHBOARD_GROUP_NOT_FOUND"
| "DASHBOARD_MODULE_ID_INVALID"
| "DASHBOARD_SORT_ORDER_INVALID"
| "DASHBOARD_READ_FAILED"
| "DASHBOARD_WRITE_FAILED";
const storeErrorMessages: Readonly<Record<DashboardStoreErrorCode, string>> = Object.freeze({
DASHBOARD_GROUP_ID_INVALID: "A Dashboard group identifier must be lowercase kebab-case.",
DASHBOARD_GROUP_LABEL_INVALID: "A Dashboard group label must be non-empty and bounded.",
DASHBOARD_GROUP_NOT_FOUND: "The Dashboard group was not found.",
DASHBOARD_MODULE_ID_INVALID: "A Dashboard placement module identifier is invalid.",
DASHBOARD_READ_FAILED: "The Dashboard navigation configuration could not be read.",
DASHBOARD_SORT_ORDER_INVALID: "A Dashboard ordering value is out of range.",
DASHBOARD_WRITE_FAILED: "The Dashboard navigation configuration could not be saved.",
});
/** Never carries the underlying database error, SQL text, host, or stack — matching
* `AuthorizationStoreError` exactly. */
export class DashboardStoreError extends Error {
readonly code: DashboardStoreErrorCode;
constructor(code: DashboardStoreErrorCode) {
super(storeErrorMessages[code]);
this.name = "DashboardStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/** A stored group. `label` is what an administrator sees; `id` is the stable key placements point
* at, and a rename never touches it. */
export interface DashboardGroupRecord {
readonly id: string;
readonly label: string;
readonly sortOrder: number;
}
/** Where one module sits. `groupId: null` is a real, chosen state — deliberately ungrouped — and is
* distinct from having no record at all, which means never configured. */
export interface DashboardPlacementRecord {
readonly moduleId: string;
readonly groupId: string | null;
readonly sortOrder: number;
}
/** The complete stored arrangement. Contains no module facts whatsoever: resolving it against the
* modules that actually exist is `resolveDashboardNavigation`'s job, not the store's. */
export interface DashboardConfiguration {
readonly groups: readonly DashboardGroupRecord[];
readonly placements: readonly DashboardPlacementRecord[];
}
export interface UpsertDashboardGroupInput {
readonly id: string;
readonly label: string;
readonly sortOrder: number;
}
export interface SetDashboardPlacementInput {
readonly moduleId: string;
/** `null` places the module in the ungrouped area deliberately. A group id that does not exist is
* rejected rather than stored, so a placement can never point at a group that was never created. */
readonly groupId: string | null;
readonly sortOrder: number;
}
export interface DashboardStore {
/** The whole stored arrangement, in deterministic order. Small by nature — one row per group and
* at most one per module — so it is read whole rather than paginated. */
readConfiguration(): Promise<DashboardConfiguration>;
/** Creates the group or replaces its label and order. Idempotent: the same input twice leaves the
* same single row, and placements pointing at it are untouched either way. */
upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord>;
/** Deletes a group. Its placements become ungrouped through the schema's `on delete set null`, so
* no dangling reference is possible and no module is lost. */
deleteGroup(groupId: string): Promise<void>;
/** Creates or replaces one module's placement. */
setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord>;
}
function assertValidGroupId(value: string): void {
if (!isValidDashboardIdentifier(value)) {
throw new DashboardStoreError("DASHBOARD_GROUP_ID_INVALID");
}
}
function assertValidModuleId(value: string): void {
if (!isValidDashboardIdentifier(value)) {
throw new DashboardStoreError("DASHBOARD_MODULE_ID_INVALID");
}
}
function normalizedLabel(value: string): string {
const trimmed = normalizeDashboardGroupLabel(value);
if (trimmed === undefined) {
throw new DashboardStoreError("DASHBOARD_GROUP_LABEL_INVALID");
}
return trimmed;
}
function assertValidSortOrder(value: number): void {
if (
!Number.isSafeInteger(value) ||
value < DASHBOARD_SORT_ORDER_MIN ||
value > DASHBOARD_SORT_ORDER_MAX
) {
throw new DashboardStoreError("DASHBOARD_SORT_ORDER_INVALID");
}
}
function rethrowAsStoreError(error: unknown, code: DashboardStoreErrorCode): never {
if (error instanceof DashboardStoreError) {
throw error;
}
throw new DashboardStoreError(code);
}
/**
* Core's persistence for Dashboard navigation configuration.
*
* Validation lives here rather than only at the HTTP boundary, so the invariants hold for any
* caller: identifiers are checked against one grammar, labels are trimmed and bounded, ordering is
* a bounded safe integer, and a placement may only name a group that exists. Nothing here reads,
* imports, or reasons about a module — a placement is a `module_id` string and no more — which is
* what keeps grouping structurally incapable of influencing discovery, dependency resolution, or
* startup order.
*/
export function createDashboardStore(db: DashboardDrizzleDatabase): DashboardStore {
return Object.freeze({
async readConfiguration(): Promise<DashboardConfiguration> {
try {
const [groups, placements] = await Promise.all([
db
.select({
id: ekklesiaCoreDashboardGroup.id,
label: ekklesiaCoreDashboardGroup.label,
sortOrder: ekklesiaCoreDashboardGroup.sortOrder,
})
.from(ekklesiaCoreDashboardGroup)
.orderBy(asc(ekklesiaCoreDashboardGroup.sortOrder), asc(ekklesiaCoreDashboardGroup.id)),
db
.select({
groupId: ekklesiaCoreDashboardPlacement.groupId,
moduleId: ekklesiaCoreDashboardPlacement.moduleId,
sortOrder: ekklesiaCoreDashboardPlacement.sortOrder,
})
.from(ekklesiaCoreDashboardPlacement)
.orderBy(
asc(ekklesiaCoreDashboardPlacement.sortOrder),
asc(ekklesiaCoreDashboardPlacement.moduleId),
),
]);
return Object.freeze({
groups: Object.freeze(groups.map((group) => Object.freeze({ ...group }))),
placements: Object.freeze(placements.map((placement) => Object.freeze({ ...placement }))),
});
} catch (error) {
rethrowAsStoreError(error, "DASHBOARD_READ_FAILED");
}
},
async upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord> {
assertValidGroupId(input.id);
const label = normalizedLabel(input.label);
assertValidSortOrder(input.sortOrder);
try {
// A rename updates `label` and `sort_order` only. `id` is the conflict target, so it is
// never rewritten and every placement pointing at this group keeps pointing at it.
const [row] = await db
.insert(ekklesiaCoreDashboardGroup)
.values({ id: input.id, label, sortOrder: input.sortOrder })
.onConflictDoUpdate({
set: { label, sortOrder: input.sortOrder, updatedAt: new Date() },
target: ekklesiaCoreDashboardGroup.id,
})
.returning({
id: ekklesiaCoreDashboardGroup.id,
label: ekklesiaCoreDashboardGroup.label,
sortOrder: ekklesiaCoreDashboardGroup.sortOrder,
});
if (!row) {
throw new DashboardStoreError("DASHBOARD_WRITE_FAILED");
}
return Object.freeze(row);
} catch (error) {
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
}
},
async deleteGroup(groupId: string): Promise<void> {
assertValidGroupId(groupId);
try {
const deleted = await db
.delete(ekklesiaCoreDashboardGroup)
.where(eq(ekklesiaCoreDashboardGroup.id, groupId))
.returning({ id: ekklesiaCoreDashboardGroup.id });
if (deleted.length === 0) {
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
}
} catch (error) {
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
}
},
async setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord> {
assertValidModuleId(input.moduleId);
assertValidSortOrder(input.sortOrder);
if (input.groupId !== null) {
assertValidGroupId(input.groupId);
}
try {
// Checked in the application rather than left to the foreign key so the caller gets
// `DASHBOARD_GROUP_NOT_FOUND` instead of a driver error. The foreign key remains the
// authority: a group deleted concurrently still fails the insert rather than storing a
// dangling reference.
if (input.groupId !== null) {
const existing = await db
.select({ id: ekklesiaCoreDashboardGroup.id })
.from(ekklesiaCoreDashboardGroup)
.where(eq(ekklesiaCoreDashboardGroup.id, input.groupId))
.limit(1);
if (existing.length === 0) {
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
}
}
const [row] = await db
.insert(ekklesiaCoreDashboardPlacement)
.values({
groupId: input.groupId,
moduleId: input.moduleId,
sortOrder: input.sortOrder,
})
.onConflictDoUpdate({
set: { groupId: input.groupId, sortOrder: input.sortOrder, updatedAt: new Date() },
target: ekklesiaCoreDashboardPlacement.moduleId,
})
.returning({
groupId: ekklesiaCoreDashboardPlacement.groupId,
moduleId: ekklesiaCoreDashboardPlacement.moduleId,
sortOrder: ekklesiaCoreDashboardPlacement.sortOrder,
});
if (!row) {
throw new DashboardStoreError("DASHBOARD_WRITE_FAILED");
}
return Object.freeze(row);
} catch (error) {
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
}
},
});
}
+16
View File
@@ -0,0 +1,16 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import { deviceSchema } from "./schema.js";
export type DeviceDrizzleDatabase = NodePgDatabase<typeof deviceSchema>;
/**
* A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`.
*
* There is deliberately no pool-constructing counterpart: device identity is only ever read and
* written by the running API, which already owns one validated pool. A second constructor would be
* a second place for transport policy to drift.
*/
export function createDeviceDrizzleView(pool: Pool): DeviceDrizzleDatabase {
return drizzle(pool, { schema: deviceSchema });
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./database.js";
export * from "./schema.js";
export * from "./store.js";
+168
View File
@@ -0,0 +1,168 @@
import { relations, sql } from "drizzle-orm";
import { check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { ekklesiaCoreUser } from "../identity/schema.js";
/**
* Core-owned device identity: paired display devices, the permissions granted to them, and the
* short-lived pairing requests that create them (ADR 0036).
*
* **Devices are not users.** There is deliberately no foreign key from a device to
* `ekklesia_core_user`, and no device row ever appears in the identity tables: a device is its own
* principal class with its own credential and its own explicit permissions. The only reference to a
* user here is *who approved a pairing*, which is audit information about a person's action, not a
* device's identity.
*
* Device permissions live in their own table rather than in `ekklesia_core_grant`. That table's
* `user_id` is a non-null foreign key into the user table, and generalizing it to a polymorphic
* subject would rewrite the platform's central authorization record — and its relationship-derived
* grants, audit semantics, and indexes — to serve one narrow case. A separate table keeps the
* device story additive and auditable while the evaluator remains the single place any decision is
* made (`core/src/authorization/evaluator.ts`).
*/
export const ekklesiaCoreDevice = pgTable(
"ekklesia_core_device",
{
id: uuid("id").primaryKey().defaultRandom(),
/** Assigned by the approving administrator, never self-asserted by the unpaired device. */
name: text("name").notNull(),
/**
* SHA-256 of the device's credential, hex encoded. The raw credential exists only in the
* response that sets the cookie and in the device's own cookie jar; it is never stored, logged,
* or recoverable from this row. A fast hash is right here — unlike a password, the credential
* is 256 bits of CSPRNG output, so there is nothing to brute-force and a slow KDF would only
* add latency to every request a display makes.
*/
credentialHash: text("credential_hash").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
createdByUserId: text("created_by_user_id").references(() => ekklesiaCoreUser.id, {
onDelete: "set null",
}),
/** Coarse operational signal, throttled by the resolver; never an identity input. */
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
/** Server-side authority. Revocation is this column, never the deletion of a remote cookie. */
revokedAt: timestamp("revoked_at", { withTimezone: true }),
revokedByUserId: text("revoked_by_user_id").references(() => ekklesiaCoreUser.id, {
onDelete: "set null",
}),
},
(table) => [
// Every credential is distinct, and the resolver looks a presented credential up by this exact
// hash — an index probe rather than a scan-and-compare, so there is no comparison to time.
uniqueIndex("ekklesia_core_device_credential_hash_idx").on(table.credentialHash),
index("ekklesia_core_device_revoked_idx").on(table.revokedAt),
check("ekklesia_core_device_name_check", sql`length(${table.name}) between 1 and 128`),
check("ekklesia_core_device_credential_hash_check", sql`length(${table.credentialHash}) = 64`),
],
);
export const ekklesiaCoreDevicePermission = pgTable(
"ekklesia_core_device_permission",
{
id: uuid("id").primaryKey().defaultRandom(),
deviceId: uuid("device_id")
.notNull()
.references(() => ekklesiaCoreDevice.id, { onDelete: "cascade" }),
permissionId: text("permission_id").notNull(),
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
grantedByUserId: text("granted_by_user_id").references(() => ekklesiaCoreUser.id, {
onDelete: "set null",
}),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
},
(table) => [
index("ekklesia_core_device_permission_lookup_idx").on(table.deviceId, table.permissionId),
check(
"ekklesia_core_device_permission_id_check",
sql`length(${table.permissionId}) between 1 and 128`,
),
],
);
/**
* One in-flight pairing attempt.
*
* Three distinct secrets meet here and must not be confused:
*
* - `code` is the short digits shown on the screen. It is **public by design** — it is displayed on
* a television in a hallway — and it identifies the request to an administrator. It is stored in
* the clear because it is not a secret and because an administrator looks a request up by it.
* - `claimSecretHash` is SHA-256 of a 256-bit secret held only in the requesting browser's
* HttpOnly cookie. It answers "are you the device that asked?", which the code cannot: anyone who
* can *see* the screen knows the code, so a flow that issued a credential on the strength of the
* code alone would hand the device's identity to whoever read it off the wall.
* - The device credential does not exist yet, and is created only when the approved request is
* consumed by the browser holding the claim secret.
*
* `expiresAt` is the only expiry authority; there is no `expired` state to write and therefore no
* sweeper that must run for correctness. `state` records only what someone *did* — nothing here
* ages a row on its own.
*/
export const ekklesiaCoreDevicePairingRequest = pgTable(
"ekklesia_core_device_pairing_request",
{
id: uuid("id").primaryKey().defaultRandom(),
code: text("code").notNull(),
claimSecretHash: text("claim_secret_hash").notNull(),
/** The display the device actually navigated to, resolved server-side from the publication
* registry. Never a value the browser chose. */
moduleId: text("module_id").notNull(),
/** Derived from that module's validated display declaration, so a device cannot ask for a
* permission of its choosing. */
permissionId: text("permission_id").notNull(),
state: text("state").notNull().default("pending"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
/** Chosen by the approving administrator; carried here until the request is consumed and the
* device row is created. */
deviceName: text("device_name"),
approvedAt: timestamp("approved_at", { withTimezone: true }),
approvedByUserId: text("approved_by_user_id").references(() => ekklesiaCoreUser.id, {
onDelete: "set null",
}),
consumedAt: timestamp("consumed_at", { withTimezone: true }),
deviceId: uuid("device_id").references(() => ekklesiaCoreDevice.id, { onDelete: "set null" }),
},
(table) => [
// At most one live request may hold a given code, which is what makes a short code safe to
// generate: a collision is refused by PostgreSQL rather than resolved by whichever row a query
// happened to return first. Consumed rows keep their code without blocking reuse later.
uniqueIndex("ekklesia_core_device_pairing_code_idx")
.on(table.code)
.where(sql`${table.state} <> 'consumed'`),
index("ekklesia_core_device_pairing_claim_idx").on(table.claimSecretHash),
check("ekklesia_core_device_pairing_code_check", sql`${table.code} ~ '^[0-9]{6}$'`),
check(
"ekklesia_core_device_pairing_claim_hash_check",
sql`length(${table.claimSecretHash}) = 64`,
),
check(
"ekklesia_core_device_pairing_state_check",
sql`${table.state} in ('pending', 'approved', 'consumed')`,
),
],
);
export const ekklesiaCoreDeviceRelations = relations(ekklesiaCoreDevice, ({ many }) => ({
permissions: many(ekklesiaCoreDevicePermission),
}));
export const ekklesiaCoreDevicePermissionRelations = relations(
ekklesiaCoreDevicePermission,
({ one }) => ({
device: one(ekklesiaCoreDevice, {
fields: [ekklesiaCoreDevicePermission.deviceId],
references: [ekklesiaCoreDevice.id],
}),
}),
);
/** The device tables, bundled exactly like `authorizationSchema`, so a Drizzle view over the
* platform's shared pool is typed against them. */
export const deviceSchema = {
ekklesiaCoreDevice,
ekklesiaCoreDevicePairingRequest,
ekklesiaCoreDevicePermission,
ekklesiaCoreDevicePermissionRelations,
ekklesiaCoreDeviceRelations,
};
+552
View File
@@ -0,0 +1,552 @@
import { createHash, randomBytes, randomInt } from "node:crypto";
import type { EkklesiaDeviceId, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm";
import type { DeviceDrizzleDatabase } from "./database.js";
import {
ekklesiaCoreDevice,
ekklesiaCoreDevicePairingRequest,
ekklesiaCoreDevicePermission,
} from "./schema.js";
export type DeviceStoreErrorCode =
| "DEVICE_PAIRING_CODE_UNAVAILABLE"
| "DEVICE_STORE_FAILED"
| "DEVICE_NOT_FOUND";
const deviceStoreMessages: Readonly<Record<DeviceStoreErrorCode, string>> = Object.freeze({
DEVICE_NOT_FOUND: "The device does not exist.",
DEVICE_PAIRING_CODE_UNAVAILABLE: "A pairing code could not be allocated. Try again.",
DEVICE_STORE_FAILED: "The device operation could not be completed.",
});
/** Never carries a credential, a claim secret, SQL, a driver error, or a cause. */
export class DeviceStoreError extends Error {
readonly code: DeviceStoreErrorCode;
constructor(code: DeviceStoreErrorCode) {
super(deviceStoreMessages[code]);
this.name = "DeviceStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* How long a pairing code is worth showing on a screen.
*
* Long enough that someone can walk from the display to a computer and type six digits; short
* enough that a code photographed off a wall is worthless by the time anyone acts on it. It is not
* the only thing protecting pairing — approval requires an authenticated administrator holding
* `core.devices.manage`, and the credential is issued only to the browser holding the claim secret
* — so this bound is defence in depth rather than the defence.
*/
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
/**
* How long a device credential is accepted before the device must pair again.
*
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
* decides on every single request whether the device is still revoked, so an administrator's revoke
* takes effect immediately regardless of how long the credential would otherwise have lived. The
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
*/
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
const PAIRING_CODE_ALLOCATION_ATTEMPTS = 8;
/**
* How many pairing requests may be waiting at once, across the whole installation.
*
* Starting a pairing needs no identity — an unpaired screen has none to offer — so without a ceiling
* anyone who can reach the platform could fill this table. A building has a handful of screens and
* pairs them one at a time, so this is far above any real use and still a hard bound. Expired rows
* are swept on the way in, so the ceiling counts genuinely live requests rather than history.
*/
const MAX_LIVE_PAIRING_REQUESTS = 50;
/** SHA-256, hex. Applied to 256-bit CSPRNG output, never to anything user-chosen. */
export function hashDeviceSecret(secret: string): string {
return createHash("sha256").update(secret, "utf8").digest("hex");
}
/** 256 bits, URL-safe. Used for both the pairing claim secret and the device credential — the two
* values that must never be guessable. */
function generateSecret(): string {
return randomBytes(32).toString("base64url");
}
/** Six digits from a rejection-free CSPRNG range, zero padded so every code is exactly six
* characters and none is easier to type than another. */
function generatePairingCode(): string {
return randomInt(0, 1_000_000).toString().padStart(6, "0");
}
export interface CreatePairingRequestInput {
readonly moduleId: string;
readonly permissionId: PermissionId;
readonly now: Date;
}
export interface CreatedPairingRequest {
readonly code: string;
readonly expiresAt: Date;
/** Returned exactly once, to be written into the device's HttpOnly cookie and never persisted or
* logged in this form. */
readonly claimSecret: string;
}
export type PairingRequestStatus = "pending" | "approved" | "expired" | "unknown";
export interface PairingRequestView {
readonly moduleId: string;
readonly permissionId: string;
readonly createdAt: Date;
readonly expiresAt: Date;
readonly status: "pending" | "approved";
}
export interface ConsumedPairing {
readonly deviceId: EkklesiaDeviceId;
/** Returned exactly once, to be written into the device's HttpOnly cookie. */
readonly credential: string;
readonly expiresAt: Date;
}
export interface DeviceSummary {
readonly id: EkklesiaDeviceId;
readonly name: string;
readonly createdAt: Date;
readonly lastSeenAt?: Date;
readonly revokedAt?: Date;
readonly permissions: readonly string[];
}
export interface ResolvedDevice {
readonly deviceId: EkklesiaDeviceId;
readonly lastSeenAt?: Date;
}
export interface DeviceStore {
createPairingRequest(input: CreatePairingRequestInput): Promise<CreatedPairingRequest>;
/** Looks a live request up by the code an administrator typed. Never used by the device itself. */
findPendingByCode(code: string, now: Date): Promise<PairingRequestView | undefined>;
approvePairingRequest(input: {
readonly code: string;
readonly deviceName: string;
readonly approvedBy: EkklesiaUserId;
readonly now: Date;
}): Promise<PairingRequestView | undefined>;
/** The device's own poll. Consumes an approved request atomically and issues the credential. */
claimPairingRequest(input: {
readonly claimSecret: string;
readonly now: Date;
}): Promise<{ readonly status: PairingRequestStatus; readonly paired?: ConsumedPairing }>;
/** Resolves a presented credential to a live, unrevoked device. */
resolveCredential(credential: string, now: Date): Promise<ResolvedDevice | undefined>;
touchLastSeen(deviceId: EkklesiaDeviceId, now: Date): Promise<void>;
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
listDevices(): Promise<readonly DeviceSummary[]>;
revokeDevice(input: {
readonly deviceId: EkklesiaDeviceId;
readonly revokedBy: EkklesiaUserId;
readonly now: Date;
}): Promise<void>;
}
function rethrowAsStoreError(error: unknown): never {
if (error instanceof DeviceStoreError) {
throw error;
}
throw new DeviceStoreError("DEVICE_STORE_FAILED");
}
export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
/** Deletes rows that can no longer do anything: expired-and-never-approved requests, and
* consumed ones past their window. Run opportunistically on request creation rather than by a
* scheduler — pairing is rare, so the only moment the table can grow is also a fine moment to
* trim it, and nothing depends on it having run. */
const sweepDeadRequests = async (now: Date): Promise<void> => {
await db
.delete(ekklesiaCoreDevicePairingRequest)
.where(
or(
and(
lt(ekklesiaCoreDevicePairingRequest.expiresAt, now),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
),
lt(ekklesiaCoreDevicePairingRequest.expiresAt, new Date(now.getTime() - 86_400_000)),
),
)
.catch(() => undefined);
};
const store: DeviceStore = {
async approvePairingRequest(input): Promise<PairingRequestView | undefined> {
try {
// Conditional on `pending` and unexpired, so approving twice, approving an expired request,
// or approving one already consumed all affect zero rows rather than racing.
const rows = await db
.update(ekklesiaCoreDevicePairingRequest)
.set({
approvedAt: input.now,
approvedByUserId: input.approvedBy,
deviceName: input.deviceName,
state: "approved",
})
.where(
and(
eq(ekklesiaCoreDevicePairingRequest.code, input.code),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now),
),
)
.returning({
createdAt: ekklesiaCoreDevicePairingRequest.createdAt,
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
moduleId: ekklesiaCoreDevicePairingRequest.moduleId,
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
});
const row = rows[0];
return row === undefined
? undefined
: Object.freeze({ ...row, status: "approved" as const });
} catch (error) {
rethrowAsStoreError(error);
}
},
async claimPairingRequest(input): Promise<{
readonly status: PairingRequestStatus;
readonly paired?: ConsumedPairing;
}> {
const claimSecretHash = hashDeviceSecret(input.claimSecret);
try {
return await db.transaction(async (tx) => {
const rows = await tx
.select({
deviceName: ekklesiaCoreDevicePairingRequest.deviceName,
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
id: ekklesiaCoreDevicePairingRequest.id,
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
state: ekklesiaCoreDevicePairingRequest.state,
approvedByUserId: ekklesiaCoreDevicePairingRequest.approvedByUserId,
})
.from(ekklesiaCoreDevicePairingRequest)
.where(eq(ekklesiaCoreDevicePairingRequest.claimSecretHash, claimSecretHash))
.limit(1);
const request = rows[0];
if (!request) {
return { status: "unknown" as const };
}
if (request.state === "consumed") {
// A credential was already issued for this request. Replaying the claim never issues a
// second one.
return { status: "unknown" as const };
}
if (request.expiresAt.getTime() <= input.now.getTime()) {
return { status: "expired" as const };
}
if (request.state !== "approved") {
return { status: "pending" as const };
}
// The single-use gate: only the transaction whose UPDATE actually matches the still
// `approved` row proceeds to mint a credential. A concurrent second claim matches zero
// rows and is told the request is gone.
const consumed = await tx
.update(ekklesiaCoreDevicePairingRequest)
.set({ consumedAt: input.now, state: "consumed" })
.where(
and(
eq(ekklesiaCoreDevicePairingRequest.id, request.id),
eq(ekklesiaCoreDevicePairingRequest.state, "approved"),
),
)
.returning({ id: ekklesiaCoreDevicePairingRequest.id });
if (consumed.length === 0) {
return { status: "unknown" as const };
}
const credential = generateSecret();
const created = await tx
.insert(ekklesiaCoreDevice)
.values({
createdByUserId: request.approvedByUserId,
credentialHash: hashDeviceSecret(credential),
name: request.deviceName ?? "Display device",
})
.returning({ id: ekklesiaCoreDevice.id });
const deviceId = created[0]?.id;
if (deviceId === undefined) {
throw new DeviceStoreError("DEVICE_STORE_FAILED");
}
// Exactly the one permission the request was created for — never a bundle, never every
// permission the module declares.
await tx.insert(ekklesiaCoreDevicePermission).values({
deviceId,
grantedByUserId: request.approvedByUserId,
permissionId: request.permissionId,
});
await tx
.update(ekklesiaCoreDevicePairingRequest)
.set({ deviceId })
.where(eq(ekklesiaCoreDevicePairingRequest.id, request.id));
return {
paired: Object.freeze({
credential,
deviceId: deviceId as EkklesiaDeviceId,
expiresAt: new Date(input.now.getTime() + DEVICE_CREDENTIAL_TTL_MS),
}),
status: "pending" as const,
};
});
} catch (error) {
rethrowAsStoreError(error);
}
},
async createPairingRequest(input): Promise<CreatedPairingRequest> {
await sweepDeadRequests(input.now);
const live = await db
.select({ id: ekklesiaCoreDevicePairingRequest.id })
.from(ekklesiaCoreDevicePairingRequest)
.where(
and(
gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
),
)
.limit(MAX_LIVE_PAIRING_REQUESTS);
if (live.length >= MAX_LIVE_PAIRING_REQUESTS) {
throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE");
}
const claimSecret = generateSecret();
const expiresAt = new Date(input.now.getTime() + DEVICE_PAIRING_REQUEST_TTL_MS);
// Collisions are resolved by the partial unique index, not by reading first and hoping: a
// concurrent request that took the same code makes this insert fail and we simply draw again.
for (let attempt = 0; attempt < PAIRING_CODE_ALLOCATION_ATTEMPTS; attempt += 1) {
const code = generatePairingCode();
try {
await db.insert(ekklesiaCoreDevicePairingRequest).values({
claimSecretHash: hashDeviceSecret(claimSecret),
code,
expiresAt,
moduleId: input.moduleId,
permissionId: input.permissionId,
});
return Object.freeze({ claimSecret, code, expiresAt });
} catch {
// Fall through and draw a different code.
}
}
throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE");
},
async findPendingByCode(code, now): Promise<PairingRequestView | undefined> {
try {
const rows = await db
.select({
createdAt: ekklesiaCoreDevicePairingRequest.createdAt,
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
moduleId: ekklesiaCoreDevicePairingRequest.moduleId,
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
state: ekklesiaCoreDevicePairingRequest.state,
})
.from(ekklesiaCoreDevicePairingRequest)
.where(
and(
eq(ekklesiaCoreDevicePairingRequest.code, code),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
gt(ekklesiaCoreDevicePairingRequest.expiresAt, now),
),
)
.limit(1);
const row = rows[0];
return row === undefined
? undefined
: Object.freeze({
createdAt: row.createdAt,
expiresAt: row.expiresAt,
moduleId: row.moduleId,
permissionId: row.permissionId,
status: "pending" as const,
});
} catch (error) {
rethrowAsStoreError(error);
}
},
async hasActivePermission(deviceId, permission): Promise<boolean> {
try {
// Joined against the device row so a revoked device has no permissions at all, without
// every permission row needing to be revoked separately.
const rows = await db
.select({ id: ekklesiaCoreDevicePermission.id })
.from(ekklesiaCoreDevicePermission)
.innerJoin(
ekklesiaCoreDevice,
eq(ekklesiaCoreDevice.id, ekklesiaCoreDevicePermission.deviceId),
)
.where(
and(
eq(ekklesiaCoreDevicePermission.deviceId, deviceId),
eq(ekklesiaCoreDevicePermission.permissionId, permission),
isNull(ekklesiaCoreDevicePermission.revokedAt),
isNull(ekklesiaCoreDevice.revokedAt),
),
)
.limit(1);
return rows.length > 0;
} catch (error) {
rethrowAsStoreError(error);
}
},
async listDevices(): Promise<readonly DeviceSummary[]> {
try {
const devices = await db
.select({
createdAt: ekklesiaCoreDevice.createdAt,
id: ekklesiaCoreDevice.id,
lastSeenAt: ekklesiaCoreDevice.lastSeenAt,
name: ekklesiaCoreDevice.name,
revokedAt: ekklesiaCoreDevice.revokedAt,
})
.from(ekklesiaCoreDevice)
.orderBy(ekklesiaCoreDevice.createdAt);
const permissions = await db
.select({
deviceId: ekklesiaCoreDevicePermission.deviceId,
permissionId: ekklesiaCoreDevicePermission.permissionId,
})
.from(ekklesiaCoreDevicePermission)
.where(isNull(ekklesiaCoreDevicePermission.revokedAt));
return Object.freeze(
devices.map((device) =>
Object.freeze({
createdAt: device.createdAt,
id: device.id as EkklesiaDeviceId,
name: device.name,
permissions: Object.freeze(
permissions
.filter((permission) => permission.deviceId === device.id)
.map((permission) => permission.permissionId)
.sort(),
),
...(device.lastSeenAt === null ? {} : { lastSeenAt: device.lastSeenAt }),
...(device.revokedAt === null ? {} : { revokedAt: device.revokedAt }),
}),
),
);
} catch (error) {
rethrowAsStoreError(error);
}
},
async resolveCredential(credential, now): Promise<ResolvedDevice | undefined> {
try {
const rows = await db
.select({
createdAt: ekklesiaCoreDevice.createdAt,
id: ekklesiaCoreDevice.id,
lastSeenAt: ekklesiaCoreDevice.lastSeenAt,
})
.from(ekklesiaCoreDevice)
.where(
and(
eq(ekklesiaCoreDevice.credentialHash, hashDeviceSecret(credential)),
isNull(ekklesiaCoreDevice.revokedAt),
),
)
.limit(1);
const row = rows[0];
if (!row) {
return undefined;
}
// The credential's own horizon, measured from when the device was paired. Revocation is
// checked above and is what makes a long horizon acceptable.
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
return undefined;
}
return Object.freeze({
deviceId: row.id as EkklesiaDeviceId,
...(row.lastSeenAt === null ? {} : { lastSeenAt: row.lastSeenAt }),
});
} catch (error) {
rethrowAsStoreError(error);
}
},
async revokeDevice(input): Promise<void> {
try {
const rows = await db
.update(ekklesiaCoreDevice)
.set({ revokedAt: input.now, revokedByUserId: input.revokedBy })
.where(
and(eq(ekklesiaCoreDevice.id, input.deviceId), isNull(ekklesiaCoreDevice.revokedAt)),
)
.returning({ id: ekklesiaCoreDevice.id });
if (rows.length === 0) {
// Either no such device or already revoked. Both are "not an active device", and telling
// the two apart would only serve enumeration.
const existing = await db
.select({ id: ekklesiaCoreDevice.id })
.from(ekklesiaCoreDevice)
.where(eq(ekklesiaCoreDevice.id, input.deviceId))
.limit(1);
if (existing.length === 0) {
throw new DeviceStoreError("DEVICE_NOT_FOUND");
}
return;
}
await db
.update(ekklesiaCoreDevicePermission)
.set({ revokedAt: input.now })
.where(
and(
eq(ekklesiaCoreDevicePermission.deviceId, input.deviceId),
isNull(ekklesiaCoreDevicePermission.revokedAt),
),
);
} catch (error) {
rethrowAsStoreError(error);
}
},
async touchLastSeen(deviceId, now): Promise<void> {
// Throttled in SQL so concurrent requests from one display cannot each decide to write.
await db
.update(ekklesiaCoreDevice)
.set({ lastSeenAt: now })
.where(
and(
eq(ekklesiaCoreDevice.id, deviceId),
or(
isNull(ekklesiaCoreDevice.lastSeenAt),
lt(
ekklesiaCoreDevice.lastSeenAt,
new Date(now.getTime() - DEVICE_LAST_SEEN_THROTTLE_MS),
),
),
),
)
.catch(() => undefined);
},
};
return Object.freeze(store);
}
/** Exported for the schema-drift check and tests; never used to build a query string by hand. */
export const DEVICE_PAIRING_CODE_PATTERN = /^[0-9]{6}$/u;
export { sql as deviceStoreSql };
+22 -2
View File
@@ -11,13 +11,33 @@ function section(title: string, checkIds: readonly DoctorCheckId[]): DoctorSecti
const sections: readonly DoctorSection[] = Object.freeze([
section("Platform", ["platform.runtime"]),
section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]),
section("Modules", [
"modules.discovery",
"modules.dependencies",
"modules.permissions",
"modules.lifecycle",
"modules.configuration",
]),
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
section("Identity", ["identity.configuration", "identity.client-address"]),
section("Object storage", ["object-storage.integration"]),
section("Object storage", ["object-storage.integration", "object-storage.bindings"]),
section("Outbound email", ["outbound-email.configuration"]),
]);
/**
* Every check id, exactly once.
*
* A check absent from `sections` is still computed and still counted toward the overall result, but
* is never shown — so a configuration failure would flip an operator's report to NOT READY with
* nothing on screen explaining why. `outbound-email.configuration` was in precisely that state, and
* `doctor-report.test.ts` now compares this list against the ids of a real report, so a new check
* cannot be added without being rendered.
*/
export const RENDERED_DOCTOR_CHECK_IDS: readonly DoctorCheckId[] = Object.freeze(
sections.flatMap((entry) => [...entry.checkIds]),
);
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
failure: "✗",
pass: "✓",
+705 -29
View File
@@ -1,23 +1,28 @@
import path from "node:path";
import {
capabilityIdSchema,
EMPTY_MODULE_SECRET_SOURCE,
isVersionCompatible,
logicalBucketIdSchema,
type ModuleSecretSource,
moduleConfigurationKeySchema,
moduleIdSchema,
permissionIdSchema,
semanticVersionSchema,
} from "@ekklesia/contracts";
import { getTableName } from "drizzle-orm";
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
import type { PostgresPoolOptions } from "../database/pool.js";
import {
assertProductionDatabaseTransport,
type DatabaseTransportEnvironment,
type DatabaseTransportPolicyInput,
DatabaseTransportError,
createDatabaseTransportPolicy,
type DatabaseTransportEnvironment,
DatabaseTransportError,
type DatabaseTransportPolicyInput,
describeDatabaseTransport,
} from "../database/transport.js";
import {
type ClientAddressConfigInput,
ClientAddressConfigError,
type ClientAddressConfigInput,
createClientAddressConfig,
} from "../identity/client-address.js";
import {
@@ -27,8 +32,17 @@ import {
} from "../identity/config.js";
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
import { identityAdapterSchema } from "../identity/schema.js";
import {
createMailProviderConfig,
createSmtpMailProvider,
describeMailProvider,
MailConfigError,
type MailProviderConfig,
type MailProviderConfigInput,
} from "../mail/index.js";
import {
createPostgresMigrationDatabase,
type DiscoverAllMigrationsOptions,
discoverAllMigrations,
type MigrationDatabase,
type MigrationPlanResult,
@@ -39,14 +53,35 @@ import {
} from "../migrations/index.js";
import {
composePlatformPermissionRegistry,
createModuleConfigurationDatabase,
createModuleConfigurationStore,
createModuleLifecycleDatabase,
createModuleLifecycleStore,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModuleSchemas,
discoverModules,
ModulePermissionCompositionError,
isConfigurationBlockedDetail,
isStorageBlockedDetail,
type ModuleConfigurationSettings,
ModuleDiscoveryError,
type ModuleDiscoveryOptions,
type ModuleEligibility,
type ModuleLifecycleConfiguration,
type ModuleMigrationAggregate,
ModulePermissionCompositionError,
type ModuleSchemaAggregate,
resolveModuleEligibility,
} from "../modules/index.js";
import {
createS3StorageProvider,
createStorageProviderConfig,
describeStorageProvider,
physicalBucketName,
StorageConfigError,
type StorageProviderConfig,
type StorageProviderConfigInput,
} from "../storage/index.js";
export type DoctorCheckId =
| "database.transport"
@@ -54,13 +89,17 @@ export type DoctorCheckId =
| "modules.discovery"
| "modules.dependencies"
| "modules.permissions"
| "modules.lifecycle"
| "modules.configuration"
| "schemas.discovery"
| "migrations.discovery"
| "postgresql.connection"
| "postgresql.plan"
| "identity.configuration"
| "identity.client-address"
| "object-storage.integration";
| "object-storage.integration"
| "object-storage.bindings"
| "outbound-email.configuration";
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
@@ -79,6 +118,7 @@ export interface DoctorReport {
export interface DoctorIdentityConfigurationOptions {
readonly baseUrl?: string | undefined;
readonly dashboardOrigin?: string | undefined;
readonly environment?: CreateIdentityConfigOptions["environment"];
readonly publicSignupEnabled?: string | undefined;
readonly secret?: string | undefined;
@@ -87,6 +127,9 @@ export interface DoctorIdentityConfigurationOptions {
export interface DoctorOptions {
readonly clientAddress?: ClientAddressConfigInput | undefined;
/** Core's own migrations, where the platform is installed. Never derived from the modules
* directory: installed modules live in installation-owned storage, anywhere at all (ADR 0048). */
readonly coreMigrationsDirectory: string;
/** The environment the executable resolved, used only for transport policy evaluation; identity
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
@@ -94,6 +137,30 @@ export interface DoctorOptions {
readonly databaseUrl?: string | undefined;
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
readonly moduleDiscovery: ModuleDiscoveryOptions;
/** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports every
* required secret as missing, which is only the honest answer for a deployment that has actually
* set none, so an executable must pass the same source it would give the runtime. */
readonly moduleSecrets?: ModuleSecretSource;
/**
* The host's object-storage settings as the executable read them, validated here.
*
* **Raw input**, for the same reason `mailConfiguration` below is raw: a half-configured provider
* is precisely the thing an operator runs Doctor to find, so Doctor has to be the one that
* validates it and reports the sanitized code. A pre-validated option meant a bad value threw
* before a report existed, and — because the executable then had to validate it itself — meant
* `pnpm doctor` simply never passed one at all, reporting configured storage as absent.
*/
readonly storageConfiguration?: StorageProviderConfigInput | undefined;
/**
* The host's outbound mail settings as the executable read them, validated here.
*
* Deliberately **raw input**, exactly like `identityConfiguration` and `storageConfiguration`: a
* malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has to
* be the one that validates it and reports the sanitized code. A pre-validated option would mean
* a bad value crashed the command instead of appearing in its report.
*/
readonly mailConfiguration?: MailProviderConfigInput | undefined;
readonly runtimeRange: string;
readonly runtimeVersion: string;
}
@@ -107,17 +174,36 @@ export interface DoctorDependencies {
options: PostgresMigrationDatabaseOptions,
) => MigrationDatabase;
readonly discoverMigrations: (
options: ModuleDiscoveryOptions,
options: DiscoverAllMigrationsOptions,
) => Promise<ModuleMigrationAggregate>;
readonly discoverModules: (
options: ModuleDiscoveryOptions,
) => Promise<readonly DiscoveredModule[]>;
readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise<ModuleSchemaAggregate>;
/** Reads the stored module enablement configuration read-only. Separated behind the dependency
* boundary like every other I/O Doctor performs, so the whole report stays exercisable without a
* database. */
readonly readModuleLifecycle: (
connectionString: string,
options: PostgresPoolOptions,
) => Promise<ModuleLifecycleConfiguration>;
/** Reads the stored module configuration read-only, behind the same dependency boundary as every
* other I/O Doctor performs. */
readonly readModuleConfiguration: (
connectionString: string,
options: PostgresPoolOptions,
) => Promise<ModuleConfigurationSettings>;
readonly planMigrations: (options: {
readonly aggregate: ModuleMigrationAggregate;
readonly database: MigrationDatabase;
}) => Promise<MigrationPlanResult>;
readonly runtimeSatisfies: (version: string, range: string) => boolean;
/** Probes the configured storage provider for reachability. Behind the dependency boundary like
* every other I/O Doctor performs, so the whole report stays exercisable without a network. */
readonly probeStorage: (config: StorageProviderConfig) => Promise<void>;
/** Opens an SMTP connection, authenticates when credentials are configured, and closes —
* **sending nothing**. Behind the dependency boundary like every other I/O Doctor performs. */
readonly probeMail: (config: MailProviderConfig) => Promise<void>;
}
const doctorPostgresTimeoutMs = 10_000;
@@ -125,22 +211,71 @@ const checksumPattern = /^[a-f0-9]{64}$/u;
const migrationIdentityPattern =
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
async function discoverAllMigrationsForRuntime(
options: ModuleDiscoveryOptions,
): Promise<ModuleMigrationAggregate> {
return discoverAllMigrations({
coreMigrationsDirectory: path.join(path.dirname(options.modulesDirectory), "core", "drizzle"),
moduleDiscovery: options,
});
/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool
* behind, and never shares one with the migration planner's, whose lifetime it does not control. */
async function readModuleLifecycleForRuntime(
connectionString: string,
options: PostgresPoolOptions,
): Promise<ModuleLifecycleConfiguration> {
const { close, db } = createModuleLifecycleDatabase(connectionString, options);
try {
return await createModuleLifecycleStore(db).readConfiguration();
} finally {
await close().catch(() => undefined);
}
}
/** Opens its own short-lived connection, reads, and always closes it — the same discipline
* `readModuleLifecycleForRuntime` follows. */
async function readModuleConfigurationForRuntime(
connectionString: string,
options: PostgresPoolOptions,
): Promise<ModuleConfigurationSettings> {
const { close, db } = createModuleConfigurationDatabase(connectionString, options);
try {
return await createModuleConfigurationStore(db).readSettings();
} finally {
await close().catch(() => undefined);
}
}
/** Opens a provider client, probes, and always closes it — Doctor never leaves a connection behind. */
async function probeStorageForRuntime(config: StorageProviderConfig): Promise<void> {
const provider = createS3StorageProvider(config);
try {
await provider.probe();
} finally {
await provider.close().catch(() => undefined);
}
}
/**
* Opens a transport, verifies, and always closes it — Doctor never leaves a connection behind.
*
* `verify()` performs the SMTP handshake and, where credentials are configured, an authentication
* exchange, then disconnects. It **never sends a message**: a diagnostic that emailed somebody every
* time an operator ran it would be its own incident.
*/
async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
const provider = createSmtpMailProvider(config);
try {
await provider.verify();
} finally {
await provider.close().catch(() => undefined);
}
}
const productionDependencies: DoctorDependencies = Object.freeze({
composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: createPostgresMigrationDatabase,
discoverMigrations: discoverAllMigrationsForRuntime,
discoverMigrations: discoverAllMigrations,
discoverModules,
discoverSchemas: discoverModuleSchemas,
planMigrations: planModuleMigrations,
probeMail: probeMailForRuntime,
probeStorage: probeStorageForRuntime,
readModuleConfiguration: readModuleConfigurationForRuntime,
readModuleLifecycle: readModuleLifecycleForRuntime,
runtimeSatisfies: isVersionCompatible,
});
@@ -177,7 +312,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
}
/**
* One line per installed module, plus — for a module that declares browser contributions (ADR 0041)
* — how many of them have a compatible provider among the installed set.
*
* Worth reporting because the failure it names is otherwise silent: a contribution whose target
* capability nobody provides, or whose provider moved outside its declared range, is simply not
* published, and an operator sees a screen that renders nothing with no error anywhere. The counts
* are derived from the same publication registry the API serves from, so this can never disagree
* with what the running platform would actually publish.
*
* Static only, like every other Doctor check: it reports what the *manifests* support and never
* claims a module is running. Enablement and dependency blocking are the lifecycle check's job.
*/
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
const publication = createModuleUiPublicationRegistry(modules);
const compatible = new Set(
publication
.listContributions()
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
);
return modules.map((module) => {
const id = module.manifest.id;
const version = module.manifest.version;
@@ -187,7 +341,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
) {
throw new TypeError("Unsafe module diagnostic value.");
}
return `${id} ${version}`;
const declared = module.contributions.length;
if (declared === 0) {
return `${id} ${version}`;
}
const resolved = module.contributions.filter((contribution) =>
compatible.has(`${id}\u0000${contribution.id}`),
).length;
return `${id} ${version} · ${declared} browser contribution${
declared === 1 ? "" : "s"
} (${resolved} with a compatible provider)`;
});
}
@@ -249,11 +412,239 @@ function permissionCompositionFailure(error: unknown): DoctorCheck {
]);
}
/**
* Renders one module's operational state in a form that is safe by construction: only a module id, a
* semantic version, a fixed state word, a fixed reason code from a closed set, and — for a
* capability edge — a capability id. Identity values are re-validated before interpolation, exactly
* like `safeModuleDetails` and `safeMigrationReference` do, so a future drift bug cannot turn this
* into a disclosure channel. No filesystem path, package name, manifest path, or caught error can
* reach this line, because none of them is on the record it reads.
*/
function safeLifecycleDetail(entry: ModuleEligibility): string {
if (
!moduleIdSchema.safeParse(entry.moduleId).success ||
!semanticVersionSchema.safeParse(entry.version).success
) {
throw new TypeError("Unsafe module lifecycle diagnostic value.");
}
const identity = `${entry.moduleId} ${entry.version}`;
if (!entry.configured.enabled) {
return `${identity} · disabled${entry.configured.explicit ? "" : " (default)"}`;
}
if (!entry.blocked) {
return `${identity} · enabled`;
}
if (isStorageBlockedDetail(entry.blocked)) {
// A storage block names the **logical** bucket the module declared — never an endpoint, a
// credential, or the physical bucket the host maps it to. Re-validated against the same grammar
// the manifest accepted it under, for the same defense-in-depth reason as every other
// interpolation here.
if (!logicalBucketIdSchema.safeParse(entry.blocked.storageBucketId).success) {
throw new TypeError("Unsafe module lifecycle diagnostic value.");
}
return `${identity} · blocked · ${entry.blocked.reason} · ${entry.blocked.storageBucketId}`;
}
if (isConfigurationBlockedDetail(entry.blocked)) {
// A configuration block names the offending **key** and never its value: a key is declared in a
// public manifest, while a value may be a credential or arbitrary operator-entered text. The key
// is re-validated against the same grammar the manifest accepted it under, for the same
// defense-in-depth reason every other interpolation here is.
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
throw new TypeError("Unsafe module lifecycle diagnostic value.");
}
return `${identity} · blocked · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`;
}
const dependency = entry.blocked.dependency;
if (!moduleIdSchema.safeParse(dependency.moduleId).success) {
throw new TypeError("Unsafe module lifecycle diagnostic value.");
}
const target =
dependency.kind === "capability" &&
dependency.capabilityId !== undefined &&
capabilityIdSchema.safeParse(dependency.capabilityId).success
? `${dependency.capabilityId} (${dependency.moduleId})`
: dependency.moduleId;
return `${identity} · blocked · ${entry.blocked.reason} · ${target}`;
}
/**
* Reports what an operator most wants to know before anything else: which installed modules this
* deployment would actually run, and why any of them would not.
*
* Configuration lives in PostgreSQL, so this check is honestly skipped when `DATABASE_URL` is not
* configured rather than reporting a default that might not be what is stored. A **disabled** module
* is a `pass` with a detail line: it is a decision somebody made, not a problem. A **blocked** module
* is a `warning`: an enabled module that cannot run is an anomaly worth surfacing, and it is exactly
* the question "why is this module unavailable?" that would otherwise send an operator to the logs.
*/
function moduleLifecycleCheck(entries: readonly ModuleEligibility[]): DoctorCheck {
const disabled = entries.filter((entry) => !entry.configured.enabled).length;
const blocked = entries.filter((entry) => entry.blocked !== undefined).length;
const eligible = entries.filter((entry) => entry.eligible).length;
const details = [
`${eligible} eligible · ${disabled} disabled · ${blocked} blocked`,
...entries.map(safeLifecycleDetail),
];
if (blocked > 0) {
return freezeCheck(
"modules.lifecycle",
"warning",
blocked === 1 ? "1 enabled module cannot run" : `${blocked} enabled modules cannot run`,
details,
);
}
return freezeCheck("modules.lifecycle", "pass", "Module lifecycle resolved", details);
}
/**
* Reports whether each installed module's configuration would actually let it run — and
* deliberately reports **nothing about the values themselves**.
*
* Not one configuration value is printed, secret or otherwise. A value is operator-entered text that
* may be an endpoint, a bucket name, or a credential that ended up in the wrong field; a *key* is
* declared in a public manifest and is safe. So a healthy module renders as a count, and an unhealthy
* one names the reason and the offending key. That is everything an operator needs to act, and
* nothing that turns `pnpm doctor` output — which people paste into chat — into a disclosure channel.
*/
function safeConfigurationDetail(entry: ModuleEligibility, declaredCount: number): string {
if (
!moduleIdSchema.safeParse(entry.moduleId).success ||
!semanticVersionSchema.safeParse(entry.version).success
) {
throw new TypeError("Unsafe module configuration diagnostic value.");
}
const identity = `${entry.moduleId} ${entry.version}`;
if (declaredCount === 0) {
return `${identity} · no configuration declared`;
}
const declared = `${declaredCount} ${plural(declaredCount, "key")} declared`;
if (entry.blocked && isConfigurationBlockedDetail(entry.blocked)) {
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
throw new TypeError("Unsafe module configuration diagnostic value.");
}
return `${identity} · ${declared} · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`;
}
if (!entry.configured.enabled) {
// Not evaluated: a disabled module's configuration is not resolved at all, and claiming it is
// valid would be asserting something nothing checked.
return `${identity} · ${declared} · not evaluated (disabled)`;
}
return `${identity} · ${declared} · valid`;
}
/**
* The configuration counterpart to `modules.lifecycle`: which installed modules could not run
* because of their own configuration, and which key is at fault.
*
* A module whose configuration is fine, or which declares none, is a `pass` line. A module blocked
* by configuration is a `warning`, for the same reason a dependency-blocked module is: an enabled
* module that cannot run is an anomaly an operator should see without reading logs.
*/
function moduleConfigurationCheck(
entries: readonly ModuleEligibility[],
declaredCounts: ReadonlyMap<string, number>,
): DoctorCheck {
const blockedByConfiguration = entries.filter(
(entry) => entry.blocked !== undefined && !("dependency" in entry.blocked),
).length;
const declaringModules = entries.filter(
(entry) => (declaredCounts.get(entry.moduleId) ?? 0) > 0,
).length;
const details = [
`${declaringModules} ${plural(declaringModules, "module")} declaring configuration · ${blockedByConfiguration} blocked`,
...entries.map((entry) =>
safeConfigurationDetail(entry, declaredCounts.get(entry.moduleId) ?? 0),
),
];
if (blockedByConfiguration > 0) {
return freezeCheck(
"modules.configuration",
"warning",
blockedByConfiguration === 1
? "1 module cannot run with its current configuration"
: `${blockedByConfiguration} modules cannot run with their current configuration`,
details,
);
}
return freezeCheck("modules.configuration", "pass", "Module configuration valid", details);
}
/**
* Reports which installed modules require object storage, and where the host keeps each binding.
*
* Names the module, the **logical** bucket it declared, and the physical bucket the host maps it to
* — the last of which is safe and useful here precisely because Doctor is an operator tool: someone
* debugging a permissions problem needs to know which bucket to look at. It is still never reachable
* from module-facing code (ADR 0030). No endpoint, access key, or secret key appears in any form.
*/
function storageBindingsCheck(
modules: readonly DiscoveredModule[],
entries: readonly ModuleEligibility[],
config: StorageProviderConfig | undefined,
): DoctorCheck {
const requiring = modules.filter((module) => module.manifest.storage.buckets.length > 0);
if (requiring.length === 0) {
// An installation whose modules need no object storage is not unhealthy for lacking a provider.
return freezeCheck("object-storage.bindings", "pass", "No module requires object storage", [
"0 modules declare a logical storage bucket.",
]);
}
const blocked = entries.filter(
(entry) => entry.blocked !== undefined && isStorageBlockedDetail(entry.blocked),
);
const details = requiring.flatMap((module) => {
const moduleId = module.manifest.id;
if (!moduleIdSchema.safeParse(moduleId).success) {
throw new TypeError("Unsafe module storage diagnostic value.");
}
return module.manifest.storage.buckets.map((bucket) => {
if (!logicalBucketIdSchema.safeParse(bucket.id).success) {
throw new TypeError("Unsafe module storage diagnostic value.");
}
return config === undefined
? `${moduleId} · ${bucket.id} · unbound (no storage provider configured)`
: `${moduleId} · ${bucket.id} → ${physicalBucketName(config.bucketPrefix, moduleId, bucket.id)}`;
});
});
if (config === undefined) {
return freezeCheck(
"object-storage.bindings",
"failure",
blocked.length === 1
? "1 module requires object storage that is not configured"
: `${blocked.length} modules require object storage that is not configured`,
[
"Configure an S3-compatible Ekklesia storage provider (S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY).",
...details,
],
);
}
return freezeCheck(
"object-storage.bindings",
"pass",
`${details.length} ${plural(details.length, "storage binding")} resolved`,
details,
);
}
function moduleDiscoveryFailureDetail(error: unknown): string {
if (!(error instanceof ModuleDiscoveryError)) {
return "Static module discovery could not be completed.";
}
// The modules directory itself, rather than anything in it (ADR 0048). Fixed wording, never the
// configured value: a path is machine detail.
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_INVALID")) {
return "The configured modules directory is not usable.";
}
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_UNREADABLE")) {
return "The configured modules directory could not be read.";
}
return "The installed module inventory is invalid.";
}
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
const details =
error instanceof Error && error.name === "ModuleDiscoveryError"
? ["The installed module inventory is invalid."]
: ["Static module discovery could not be completed."];
const details = [moduleDiscoveryFailureDetail(error)];
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details);
}
@@ -349,6 +740,14 @@ function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
}
return `${entry.status} · ${safeMigrationReference(entry.identity, entry.checksum)}`;
}),
// An absent module's applied history (ADR 0048) is information, never attention: removing a
// module's code keeps its data, and nothing here is pending.
...plan.retained.map((module) => {
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(module.moduleId)) {
throw new TypeError("Unsafe migration plan diagnostic value.");
}
return `retained · ${module.moduleId} · ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`;
}),
];
if (pendingCount + blockedCount === 0) {
@@ -373,6 +772,35 @@ function identitySchemaSummary(): string {
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
}
/**
* A heuristic for "same site" between two canonical origins, for a warning only (ADR 0045).
*
* The same hostname on another port is the same site. Otherwise the two hostnames must be siblings:
* identical after their first label, with that parent itself holding at least one dot
* (`dash.example.org` and `api.example.org`). Without the Public Suffix List this can be wrong in
* both directions for unusual names, which is why its result is a warning and never a failure.
*/
function plausiblySameSite(dashboardOrigin: string, apiOrigin: string): boolean {
const dashboard = new URL(dashboardOrigin).hostname;
const api = new URL(apiOrigin).hostname;
if (dashboard === api) {
return true;
}
// An IP address is a site of its own; two different ones never share one.
const isIpAddress = (hostname: string): boolean =>
hostname.startsWith("[") || /^[0-9]+(\.[0-9]+){3}$/u.test(hostname);
if (isIpAddress(dashboard) || isIpAddress(api)) {
return false;
}
const parent = (hostname: string): string => hostname.slice(hostname.indexOf(".") + 1);
return (
dashboard.includes(".") &&
api.includes(".") &&
parent(dashboard) === parent(api) &&
parent(dashboard).includes(".")
);
}
/**
* Pure configuration validation only: never constructs Better Auth, never opens a database
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
@@ -385,6 +813,7 @@ function identityConfigurationCheck(
const hasAnyValue =
input?.secret !== undefined ||
input?.baseUrl !== undefined ||
(input?.dashboardOrigin ?? "") !== "" ||
input?.publicSignupEnabled !== undefined ||
(input?.trustedOrigins?.length ?? 0) > 0;
@@ -399,17 +828,43 @@ function identityConfigurationCheck(
const config = createIdentityConfig(
{
baseUrl: input?.baseUrl,
// Validated exactly as the API validates it at startup, so a Dashboard origin the API would
// refuse (untrusted, insecure, malformed) fails here first.
dashboardOrigin: input?.dashboardOrigin,
publicSignupEnabled: input?.publicSignupEnabled,
secret: input?.secret,
trustedOrigins: input?.trustedOrigins,
},
{ environment: input?.environment ?? "production" },
);
const signupDetail = config.publicSignupEnabled
? "Public signup is explicitly ENABLED."
: "Public signup is disabled (safe default).";
if (config.dashboardOrigin === config.baseUrl) {
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
schemaDetail,
signupDetail,
"Topology: same-origin (the Dashboard is served from the API's origin).",
]);
}
const topologyDetail = "Topology: split (the Dashboard and the API are on separate origins).";
if (!plausiblySameSite(config.dashboardOrigin, config.baseUrl)) {
return freezeCheck(
"identity.configuration",
"warning",
"Identity configuration valid; split origins may not share a site",
[
schemaDetail,
signupDetail,
topologyDetail,
"The Dashboard and API hostnames are not siblings under one parent domain. Browsers send the session cookie across split origins only within one registrable domain (ADR 0045); Doctor cannot check the Public Suffix List, so confirm it.",
],
);
}
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
schemaDetail,
config.publicSignupEnabled
? "Public signup is explicitly ENABLED."
: "Public signup is disabled (safe default).",
signupDetail,
topologyDetail,
]);
} catch (error) {
const code =
@@ -465,17 +920,25 @@ function databaseTransportCheck(
}
}
/** Doctor reads only its own process's environment, so it says so: run as the reference
* deployment's `migrate`, it is given the API's values by the proxy overlay, but run any other way
* it may be checking a configuration the API is not running. */
const CLIENT_ADDRESS_SCOPE =
"Checked: the environment Doctor runs with. In the reference deployment `migrate` is given the API's client-address values; a running API's own value is shown by `docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.";
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
try {
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
if (config.mode === "direct") {
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
CLIENT_ADDRESS_SCOPE,
]);
}
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
CLIENT_ADDRESS_SCOPE,
]);
} catch (error) {
const code =
@@ -486,7 +949,7 @@ function clientAddressCheck(input: ClientAddressConfigInput | undefined): Doctor
"identity.client-address",
"failure",
"Client-address configuration invalid",
[`[${code}] Configured client-address values failed validation.`],
[`[${code}] Configured client-address values failed validation.`, CLIENT_ADDRESS_SCOPE],
);
}
}
@@ -508,6 +971,23 @@ export async function diagnoseEkklesia(
options: DoctorOptions,
dependencies: DoctorDependencies = productionDependencies,
): Promise<DoctorReport> {
// Resolved first, before module eligibility is decided: whether a provider is configured is an
// input to whether a module that declares a bucket can run, so validating it later would mean
// deciding eligibility against a question that had not been answered yet.
const hostEnvironment =
options.databaseEnvironment === "development" ? "development" : "production";
let storageConfig: StorageProviderConfig | undefined;
let storageConfigurationError: string | undefined;
try {
storageConfig = createStorageProviderConfig(options.storageConfiguration, {
environment: hostEnvironment,
});
} catch (error) {
// The sanitized code only — never the endpoint, the access key, or the secret key.
storageConfigurationError =
error instanceof StorageConfigError ? error.code : "STORAGE_CONFIGURATION_INVALID";
}
let runtimeCheck: DoctorCheck;
try {
runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange)
@@ -574,6 +1054,74 @@ export async function diagnoseEkklesia(
}
}
// Deliberately independent of permission composition, schema, and migration discovery: an
// operator asking "is this module running?" must still get an answer when an unrelated check
// failed. It needs only a successful discovery and a configured database.
let lifecycleCheck: DoctorCheck;
let configurationCheck: DoctorCheck;
// Hoisted so the storage-bindings check below reports the same eligibility this pass resolved,
// rather than resolving a second time and risking a different answer.
let eligibilityEntries: readonly ModuleEligibility[] = [];
const skippedConfiguration = (detail: string): DoctorCheck =>
freezeCheck("modules.configuration", "skipped", "Module configuration skipped", [detail]);
if (!discoveredModules) {
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
"Complete module discovery is required.",
]);
configurationCheck = skippedConfiguration("Complete module discovery is required.");
} else if (typeof options.databaseUrl !== "string" || options.databaseUrl.trim() === "") {
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
"DATABASE_URL is not configured.",
]);
configurationCheck = skippedConfiguration("DATABASE_URL is not configured.");
} else {
try {
const lifecycleTransport = createDatabaseTransportPolicy(options.databaseTransport, {
environment: options.databaseEnvironment ?? "production",
});
const poolOptions = {
connectionTimeoutMs: doctorPostgresTimeoutMs,
operationTimeoutMs: doctorPostgresTimeoutMs,
transport: lifecycleTransport,
};
// Both axes read and resolved in the one pass the platform itself uses, so Doctor can never
// report a module as runnable that startup would block, or the reverse.
// Sequential for the same reason `startApi` reads them sequentially: a deterministic failure
// beats two tiny queries racing.
const configuration = await dependencies.readModuleLifecycle(
options.databaseUrl,
poolOptions,
);
const settings = await dependencies.readModuleConfiguration(options.databaseUrl, poolOptions);
const resolution = resolveModuleEligibility(
discoveredModules,
configuration,
{ secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, settings },
{ providerConfigured: storageConfig !== undefined },
);
eligibilityEntries = resolution.entries;
lifecycleCheck = moduleLifecycleCheck(resolution.entries);
configurationCheck = moduleConfigurationCheck(
resolution.entries,
new Map(
discoveredModules.map(
(module) => [module.manifest.id, module.manifest.configuration.length] as const,
),
),
);
} catch {
lifecycleCheck = freezeCheck("modules.lifecycle", "failure", "Module lifecycle unavailable", [
"The stored module enablement configuration could not be read safely.",
]);
configurationCheck = freezeCheck(
"modules.configuration",
"failure",
"Module configuration unavailable",
["The stored module configuration could not be read safely."],
);
}
}
let schemaCheck: DoctorCheck;
let migrationCheck: DoctorCheck;
let migrations: ModuleMigrationAggregate | undefined;
@@ -599,7 +1147,10 @@ export async function diagnoseEkklesia(
}
try {
migrations = await dependencies.discoverMigrations(options.moduleDiscovery);
migrations = await dependencies.discoverMigrations({
coreMigrationsDirectory: options.coreMigrationsDirectory,
moduleDiscovery: options.moduleDiscovery,
});
const inventory = migrations.migrations.map((migration) =>
safeMigrationReference(migration.identity, migration.checksum),
);
@@ -659,15 +1210,138 @@ export async function diagnoseEkklesia(
options.databaseEnvironment,
);
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
"Core storage integration has not been implemented yet.",
]);
// Two separate questions, deliberately. "Is a provider configured and reachable?" is
// infrastructure diagnostics; "can every module's declared bucket be bound?" is what decides
// whether modules run. Conflating them would let a momentary network blip read as a module
// problem (ADR 0030).
let storageCheck: DoctorCheck;
if (storageConfigurationError !== undefined) {
// A failure, unlike an unreachable provider: this is not a network condition an operator can
// wait out. `startApi` refuses to start on exactly this input, so a deployment carrying it is
// genuinely not ready, and saying so is what stops the operator from discovering it at boot.
storageCheck = freezeCheck(
"object-storage.integration",
"failure",
"Object storage configuration invalid",
[
`The object-storage settings were rejected: ${storageConfigurationError}.`,
"Nothing was connected to and no credential is shown. Correct the S3_* values and run Doctor again.",
],
);
} else if (!storageConfig) {
storageCheck = freezeCheck(
"object-storage.integration",
"skipped",
"Object storage not configured",
["No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage."],
);
} else {
try {
await dependencies.probeStorage(storageConfig);
storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [
describeStorageProvider(storageConfig),
// Said plainly rather than implied: this probe used the endpoint Core talks to, and a
// separate browser-facing origin is by definition something a browser reaches and this
// process may not. Reporting "reachable" without that caveat would let an operator conclude
// direct delivery works when only half of it was tested.
...(storageConfig.deliveryEndpoint === storageConfig.endpoint
? []
: [
"The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.",
]),
]);
} catch {
// A warning, never a failure: the provider is configured, and a module that is already
// running keeps running through a transient outage. Reporting this as `not-ready` would make
// the platform look broken because a network hiccup happened while Doctor ran.
storageCheck = freezeCheck(
"object-storage.integration",
"warning",
"Object storage unreachable",
[
describeStorageProvider(storageConfig),
"The configured storage service did not respond. Modules already running are unaffected.",
],
);
}
}
const storageBindings = discoveredModules
? storageBindingsCheck(discoveredModules, eligibilityEntries, storageConfig)
: freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [
"Complete module discovery is required.",
]);
// Outbound email, reported the same way storage is: configured-and-reachable, configured-but-not
// answering, or simply absent. Absent is deliberately `skipped` rather than a warning — nothing in
// the platform sends email yet, so an installation without SMTP is complete rather than degraded
// (ADR 0037).
let mailCheck: DoctorCheck;
let mailConfig: MailProviderConfig | undefined;
let mailConfigurationError: string | undefined;
try {
mailConfig = createMailProviderConfig(options.mailConfiguration, {
environment: hostEnvironment,
});
} catch (error) {
// The sanitized code only — never the host, the username, or the password.
mailConfigurationError =
error instanceof MailConfigError ? error.code : "MAIL_CONFIGURATION_INVALID";
}
if (mailConfigurationError !== undefined) {
mailCheck = freezeCheck(
"outbound-email.configuration",
"failure",
"Outbound email configuration invalid",
[
`The SMTP settings were rejected: ${mailConfigurationError}.`,
"Nothing was connected to and no credential is shown. Correct the EKKLESIA_SMTP_* values and run Doctor again.",
],
);
} else if (!mailConfig) {
mailCheck = freezeCheck(
"outbound-email.configuration",
"skipped",
"Outbound email not configured",
[
"No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.",
],
);
} else {
try {
await dependencies.probeMail(mailConfig);
mailCheck = freezeCheck("outbound-email.configuration", "pass", "Outbound email reachable", [
describeMailProvider(mailConfig),
// Said plainly: this connected and, where credentials are set, authenticated. It did not
// send anything, so it proves the relay accepts this deployment — not that a message would
// reach an inbox, which depends on SPF, DKIM, DMARC, and the relay's own authorization
// policy and cannot be established from here.
"Connected without sending a message. Deliverability still depends on the relay's authorization policy and this domain's SPF/DKIM/DMARC records.",
]);
} catch {
// A warning, never a failure, exactly as for storage: the configuration is valid and nothing
// in the platform depends on mail yet, so a relay that is briefly unreachable — or one that
// declines a bare connectivity check — must not make the deployment read as broken.
mailCheck = freezeCheck(
"outbound-email.configuration",
"warning",
"Outbound email unreachable",
[
describeMailProvider(mailConfig),
"The configured SMTP service did not accept a connection. Some relays also refuse a connection that sends no message, so verify with a real send before treating this as an outage.",
],
);
}
}
return publishReport([
runtimeCheck,
moduleCheck,
dependencyCheck,
permissionsCheck,
lifecycleCheck,
configurationCheck,
schemaCheck,
migrationCheck,
transportCheck,
@@ -676,5 +1350,7 @@ export async function diagnoseEkklesia(
identityCheck,
clientAddressDoctorCheck,
storageCheck,
storageBindings,
mailCheck,
]);
}

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