Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 ba60f70835 feat(shell): make module pages first-class top-level routes
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 43s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m12s
CI / image-build (pull_request) Successful in 1m0s
CI / deploy-smoke (same-origin) (pull_request) Successful in 56s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
Module pages move from `/modules/<id>` to `/<module-id>` (`/signage`),
beside `/settings` and `/account`, so the implementation concept
"module" never appears in a user-facing address. ADR 0057 is rewritten
to match.

- Core reserves the top-level names the platform actually serves today
  (`account`, `api`, `assets`, `display`, `settings`), and discovery
  refuses a module that declares one with `MODULE_ID_RESERVED`. That
  covers startup, `modules:validate`, and package verification. Tests in
  `apps/api` hold the set to every top-level route the API registers and
  every page the shell interprets.
- The API serves the Dashboard page at `/<module-id>` and below only for
  modules discovered with a Dashboard UI, enabled or not: a disabled
  module's bookmark still opens the Dashboard, which says it is
  unavailable. An arbitrary root path such as
  `/definitely-not-a-real-module` stays the sanitized 404.
- Both reference proxies forward every top-level path shaped like a
  module id and let the API decide; the split Dashboard hostname still
  refuses `/api`, `/display`, and `/assets`. Installing a module needs no
  proxy change.
- `/<module-id>/<page>` is already served and forwarded, so a later
  module sub-page contract needs no routing or deployment change; the
  shell shows "Page not found" there until it exists.
- The deploy smoke checks a module's address before install, while
  installed, while disabled, after removal, and after reinstall, plus
  unknown names and the old `/modules/…` form through the real proxy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 08:42:34 -05:00
ekklesiaandClaude Opus 5.5 9afe9376c4 feat(shell): give every Dashboard page a real URL
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m17s
CI / image-build (pull_request) Successful in 1m10s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
The Dashboard had one address. The API served the page only at `/`, both
reference proxies forwarded only `/`, and the open module lived in React
state, so a refresh went back to the first module, nothing could be
bookmarked, and Back left the Dashboard (DESIGN.md §2.7).

ADR 0057 makes the address bar the source of truth:

- `/` is a host-owned Overview, now the landing page; `/modules/<id>`
  mounts that module. An id the server's navigation does not list shows
  "This page isn't available" and loads nothing; any other Dashboard
  address is a host-owned "Page not found".
- `/modules`, `/settings`, and `/account` are reserved whole, from one
  list (`@ekklesia/shell/dashboard-routes`) that the shell's router and
  the API's page routes both read. The API serves the same frozen page,
  ETag, and security headers anywhere inside them; look-alikes and other
  methods stay the sanitized 404.
- A small host-owned history replaces the selected-module state, built in
  the entry point so it stays the only file touching `window`. Navigation
  items are real links; modified clicks are left to the browser. Focus
  moves to the page after an in-place change, and the tab is named after
  the page.
- Sign-in still happens in place with no return value (ADR 0034), so a
  deep link opens its own page after sign-in.
- Both reference Caddyfiles forward the new paths; the deploy smoke now
  reloads a module's address through the real proxy and requires it to
  mount again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 08:11:02 -05:00
Zayin_Admin 44430b5d59 Merge pull request 'test(api): let a shutdown's closed connections leave pg_stat_activity' (#100) from fix/flaky-shutdown-connection-check into main
CI / verify (push) Successful in 1m10s
CI / infrastructure-smoke (push) Successful in 35s
CI / package-release (push) Successful in 49s
CI / image-build (push) Successful in 32s
CI / migration-smoke (push) Successful in 2m20s
CI / deploy-smoke (same-origin) (push) Successful in 55s
CI / deploy-smoke (split) (push) Successful in 2m25s
CI / package-publication (push) Successful in 1m2s
Reviewed-on: #100
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 04:41:38 +00:00
Zayin_Admin bef516796e Merge branch 'main' into fix/flaky-shutdown-connection-check
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 40s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m24s
CI / image-build (pull_request) Successful in 1m2s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m36s
2026-10-14 04:23:11 +00:00
Zayin_Admin 9bef1d9ef2 Merge pull request 'docs(claude): file an issue for what you find outside the task' (#99) from docs/claude-md-report-findings into main
CI / verify (push) Successful in 1m15s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 2m16s
CI / package-release (push) Successful in 47s
CI / image-build (push) Successful in 30s
CI / deploy-smoke (same-origin) (push) Successful in 55s
CI / deploy-smoke (split) (push) Successful in 2m30s
CI / package-publication (push) Successful in 53s
Reviewed-on: #99
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 04:23:04 +00:00
ekklesiaandClaude Opus 5.5 f2fc069e66 test(api): let a shutdown's closed connections leave pg_stat_activity
CI / verify (pull_request) Successful in 1m16s
CI / infrastructure-smoke (pull_request) Successful in 40s
CI / package-release (pull_request) Successful in 57s
CI / image-build (pull_request) Successful in 1m1s
CI / migration-smoke (pull_request) Successful in 2m30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 55s
CI / deploy-smoke (split) (pull_request) Successful in 2m41s
Four integration tests shut the API down and immediately count the
database's rows in pg_stat_activity, expecting zero. PostgreSQL ends a
backend asynchronously: pool.end() resolves once the client side has
closed, and the server can still list that backend for a moment. On a
loaded runner an immediate count saw one, failing migration-smoke on a
docs-only PR (Ekklesia CI run #238, module-database.integration.ts:764,
"expected 1 to be +0").

Reproduced against postgres:18.4-alpine3.24 limited to 0.05 CPU: after
pool.end() resolved, the backend was still listed in 7 of 300 trials,
and gone about a millisecond later.

The checks now wait up to 3 s for the count to reach zero. A connection
that is genuinely left open is still caught: an idle pooled client
stays listed for pg-pool's 10 s idle timeout, far past the deadline.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:13:21 -05:00
ekklesiaandClaude Opus 5.5 0552442c7c docs(claude): file an issue for what you find outside the task
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 28s
CI / package-release (pull_request) Successful in 53s
CI / package-publication (pull_request) Successful in 1m3s
CI / image-build (pull_request) Successful in 1m1s
CI / deploy-smoke (same-origin) (pull_request) Successful in 59s
CI / deploy-smoke (split) (pull_request) Successful in 2m35s
CI / migration-smoke (pull_request) Successful in 2m9s
Findings that only live in a chat reply get lost. Agents now open a
descriptive issue in the repository a problem belongs to (after
searching for an existing one), mark it found-by-claude, leave it
unassigned, and list what they filed when they report back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:58:17 -05:00
Zayin_Admin 63fda0f8d6 Merge pull request 'fix(ci): confine the sweep self-test to its own run' (#96) from fix/ci-sweep-self-test-scope into main
CI / verify (push) Successful in 1m13s
CI / infrastructure-smoke (push) Successful in 34s
CI / migration-smoke (push) Successful in 2m11s
CI / package-release (push) Successful in 53s
CI / image-build (push) Successful in 36s
CI / deploy-smoke (same-origin) (push) Successful in 58s
CI / deploy-smoke (split) (push) Successful in 2m41s
CI / package-publication (push) Successful in 1m3s
Reviewed-on: #96
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 03:55:35 +00:00
ekklesiaandClaude Opus 5.5 94fa7a17a8 fix(ci): confine the sweep self-test to its own run
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m21s
CI / image-build (pull_request) Successful in 59s
CI / deploy-smoke (same-origin) (pull_request) Successful in 52s
CI / deploy-smoke (split) (pull_request) Successful in 2m35s
CI / package-publication (pull_request) Successful in 1m3s
scripts/test-ci-sweep.sh proves the stale sweep by running
`ci-sweep.sh --stale --max-age-seconds 0` on the runner's shared Docker
daemon. With a zero threshold that removes every `ekklesia-ci-` stack on
the daemon, not just the test's own: any job running at that moment
loses its containers, networks, and volumes. It reported "CI sweep
checks passed" while doing it. Core's infrastructure-smoke job runs the
self-test on every run, and twice tore down Signage's integration stack
mid-scenario (Zayin/Signage run 288: its first attempt, and the second
one during connectivity-blip).

`ci-sweep.sh --stale` now takes `--prefix P`, which narrows the sweep to
stacks whose names begin with P and must itself begin with the CI
prefix, so it can only narrow a sweep, never widen one. The self-test
confines every stale sweep to its own fake run and proves that another
run's stack survives even the zero threshold. The workflow's own sweeps
are unchanged.

Regression: a bystander stack from another run in the self-test, and a
Docker-free guard that every stale sweep in the self-test is confined
and the workflow's keep the default threshold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:46:38 -05:00
Zayin_Admin 437720f228 Merge pull request 'chore(release): prepare 0.2.5' (#95) from chore/release-0.2.5 into main
CI / verify (push) Successful in 1m15s
CI / infrastructure-smoke (push) Successful in 35s
CI / package-release (push) Successful in 59s
CI / package-publication (push) Successful in 58s
CI / image-build (push) Successful in 32s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / deploy-smoke (split) (push) Successful in 2m25s
CI / migration-smoke (push) Successful in 2m25s
Reviewed-on: #95
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 03:37:04 +00:00
ekklesiaandClaude Opus 5.5 d3148d45f7 chore(release): prepare 0.2.5
CI / verify (pull_request) Successful in 1m11s
CI / infrastructure-smoke (pull_request) Successful in 43s
CI / package-release (pull_request) Successful in 52s
CI / image-build (pull_request) Successful in 1m8s
CI / migration-smoke (pull_request) Successful in 2m25s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 59s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:28:08 -05:00
Zayin_Admin 303d455315 Merge pull request 'chore: publish packages to the registry's current Zayin location' (#93) from chore/zayin-package-registry into main
CI / verify (push) Successful in 1m10s
CI / infrastructure-smoke (push) Successful in 39s
CI / package-release (push) Successful in 49s
CI / image-build (push) Successful in 32s
CI / migration-smoke (push) Successful in 2m15s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 2m37s
CI / package-publication (push) Successful in 1m4s
Reviewed-on: #93
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 03:19:45 +00:00
Zayin_Admin 39f48dc7c9 Merge branch 'main' into chore/zayin-package-registry
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 30s
CI / package-release (pull_request) Successful in 46s
CI / image-build (pull_request) Successful in 1m0s
CI / migration-smoke (pull_request) Successful in 2m13s
CI / deploy-smoke (same-origin) (pull_request) Successful in 56s
CI / package-publication (pull_request) Successful in 1m0s
CI / deploy-smoke (split) (pull_request) Successful in 2m36s
2026-10-14 03:09:53 +00:00
Zayin_Admin 9d515cd56f Merge pull request 'fix(devices): keep an in-use display paired by renewing its credential (ADR 0056)' (#94) from fix/device-credential-renewal into main
CI / verify (push) Successful in 1m8s
CI / infrastructure-smoke (push) Successful in 31s
CI / package-release (push) Successful in 49s
CI / image-build (push) Successful in 32s
CI / migration-smoke (push) Successful in 2m14s
CI / deploy-smoke (same-origin) (push) Successful in 47s
CI / deploy-smoke (split) (push) Successful in 2m30s
CI / package-publication (push) Successful in 56s
Reviewed-on: #94
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 03:08:17 +00:00
Zayin_Admin b8a826f8be Merge branch 'main' into fix/device-credential-renewal
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 38s
CI / package-release (pull_request) Successful in 1m2s
CI / migration-smoke (pull_request) Successful in 2m14s
CI / image-build (pull_request) Successful in 56s
CI / deploy-smoke (same-origin) (pull_request) Successful in 47s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m34s
2026-10-14 02:07:43 +00:00
Zayin_Admin 0c9b8f7af9 Merge pull request 'fix(display): Stage 0 display continuity, revocation, and kiosk recovery' (#92) from stage0/display-runtime-continuity into main
CI / verify (push) Successful in 1m12s
CI / infrastructure-smoke (push) Successful in 31s
CI / package-release (push) Successful in 50s
CI / image-build (push) Successful in 33s
CI / migration-smoke (push) Successful in 2m21s
CI / deploy-smoke (same-origin) (push) Successful in 47s
CI / deploy-smoke (split) (push) Successful in 2m31s
CI / package-publication (push) Successful in 56s
Reviewed-on: #92
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 02:06:01 +00:00
ekklesiaandClaude Opus 5.5 5e9433d96b docs(deploy): describe the in-use credential renewal in the kiosk guide
CI / verify (pull_request) Successful in 1m28s
CI / infrastructure-smoke (pull_request) Successful in 40s
CI / package-release (pull_request) Successful in 47s
CI / image-build (pull_request) Successful in 59s
CI / migration-smoke (pull_request) Successful in 2m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 2m38s
CI / package-publication (pull_request) Successful in 1m1s
ADR 0056 (Zayin/Ekklesia#94) replaces the fixed 180 days from pairing with
180 days from last use, renewed while the screen is in use; the guide no
longer tells operators to track pairing dates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:30:17 -05:00
ekklesiaandClaude Opus 5.5 650932a170 fix(devices): keep an in-use display paired, renewing its credential instead of expiring it
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 39s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m22s
CI / image-build (pull_request) Successful in 1m1s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m34s
A device credential was accepted for a fixed 180 days from pairing, so every
working screen dropped to a pairing code on the anniversary of its pairing,
and screens paired together failed together. Measure the 180 days from when
the device was last seen instead, and have the display descriptor - which
every screen requests on start, on reconnect, and periodically - re-issue the
cookie that authenticated it with a fresh lifetime. The value is not rotated,
so a renewal lost in transit cannot strand a screen. Revoked, expired, and
unknown credentials are never renewed, a person never receives a device
cookie, and an abandoned screen still stops being an identity 180 days after
it was last seen. No migration: lastSeenAt already slides with use.

ADR 0056 supersedes the session-lifetime section of ADR 0036.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:27:27 -05:00
ekklesiaandClaude Opus 5.5 2d861c6bf0 chore: publish and document packages at the registry's current Zayin location
CI / verify (pull_request) Successful in 1m17s
CI / infrastructure-smoke (pull_request) Successful in 31s
CI / package-release (pull_request) Successful in 49s
CI / image-build (pull_request) Successful in 59s
CI / migration-smoke (pull_request) Successful in 2m16s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m39s
The package registry moved with the repositories to
git.zayin.net/api/packages/Zayin/npm/; the old River_of_Glory_Church address
now answers 502, so the next manual Publish packages run would have failed.
Point the workflow, the release and module-development docs, and the
disposable-Gitea publication proof's owner at Zayin. The CI Docker label keys
keep their names: they are identifiers the sweep matches on, not hosts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:23:52 -05:00
ekklesiaandClaude Opus 5.5 bae4ff3c6a test(deploy): re-pair the kiosk after the revoke scenario
CI / verify (pull_request) Successful in 1m16s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m14s
CI / image-build (pull_request) Successful in 1m0s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Successful in 2m35s
CI / package-publication (pull_request) Successful in 1m1s
The revoke scenario left the kiosk revoked, so any display check after it -
the post-recreation restart check in particular - failed by construction.
That is why Signage could never run it. Finish the scenario the way the
roadmap states the requirement: a revoked screen shows a fresh code, is paired
again, resumes without a reload, and for Signage keeps its existing roster
entry rather than appearing as a new screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:02:35 -05:00
ekklesiaandClaude Opus 5.5 9331a8f7df fix(display): retry a display that could not start, and re-resolve a running one periodically
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 38s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m9s
CI / image-build (pull_request) Successful in 57s
CI / deploy-smoke (same-origin) (pull_request) Successful in 53s
CI / deploy-smoke (split) (pull_request) Successful in 2m35s
CI / package-publication (pull_request) Successful in 1m1s
A kiosk that booted before its server, as after a power cut, stopped on 'This
display could not be reached' until someone pressed Retry: with the network
up, no online event ever came. It now retries on its own, from two seconds
doubling to a minute. A running display also re-resolves its descriptor every
five minutes in the background, so a module upgraded on the server is picked
up without a reload, and the same answer disturbs nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:45:37 -05:00
ekklesiaandClaude Opus 5.5 8f90871be0 docs(deploy): document how to set up an unattended display kiosk
Stage 0 requires that kiosk setup can be reproduced from documentation, and
there was none. Cover the address, a single-launcher Chromium command with a
persistent profile, a systemd user unit, pairing and the fixed 180-day device
credential, what the screen does through each kind of outage, the known offline
cold-start limit, and why a second launcher produces duplicate windows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:45:37 -05:00
ekklesiaandClaude Opus 5.5 85b1377fd3 fix(devices): confirm before revoking a display
Revoke acted on a single click, yet it strands the screen until someone
re-pairs it in person. Explain the consequence in the shared in-product
Dialog first (DESIGN 21.4), with an obvious Cancel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:31:36 -05:00
ekklesiaandClaude Opus 5.5 4bcdb578e6 fix(display): keep a live display across reconnects, and return a revoked one to pairing
Every browser 'online' event reset the display host to its loading state and
mounted the module again: a fresh viewer session, a fresh stage, and every
cached object URL released, on each Wi-Fi blip. And a device revoked while its
display was open never noticed; it kept playing cached content and polling
403s until someone reloaded the page.

The host now re-resolves its descriptor in the background (ADR 0043) and only
replaces the frame when the answer actually changed: a new version or
principal remounts, a refusal returns the screen to pairing (ADR 0036), and
any other failure keeps the running display. A module request refused with
401/403 prompts the same revalidation, throttled, so revocation is noticed
within one heartbeat. The offline indicator waits ten seconds before
appearing, so a momentary blip shows nothing, and no longer names the
platform on a lobby wall.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:31:36 -05:00
ekklesiaandClaude Opus 5.5 06bad2fe36 docs: bring CLAUDE.md in line with the current repositories and product rules
Core's agent guidance still described modules living in this monorepo and
manifest v7, and said nothing about the product rules that now govern every
change. Record that first-party modules live in their own Zayin repositories
and install as Module Packages, that the parent ROADMAP.md and DESIGN.md are
authoritative for product direction, the ownership, branding, token, dark-mode
and no-native-dialog rules, that display pairing is transitional until Stage 1,
and the branch-per-PR, no-push-to-main, and no-release-tag workflow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:31:36 -05:00
The_River ae976ca229 Merge pull request 'chore(release): prepare 0.2.4' (#91) from chore/release-0.2.4 into main
CI / verify (push) Successful in 1m13s
CI / infrastructure-smoke (push) Successful in 38s
CI / package-release (push) Successful in 49s
CI / image-build (push) Successful in 30s
CI / migration-smoke (push) Successful in 2m13s
CI / deploy-smoke (same-origin) (push) Successful in 47s
CI / package-publication (push) Successful in 1m4s
CI / deploy-smoke (split) (push) Successful in 2m23s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/91
Reviewed-by: The_River <[email protected]>
2026-10-10 17:09:10 +00:00
ekklesia f9585cc7ec chore(release): prepare 0.2.4
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / package-release (pull_request) Successful in 34s
CI / image-build (pull_request) Successful in 59s
CI / deploy-smoke (same-origin) (pull_request) Successful in 47s
CI / deploy-smoke (split) (pull_request) Successful in 2m21s
CI / package-publication (pull_request) Successful in 49s
2026-10-10 11:54:00 -05:00
The_River 6dae29412f Merge pull request 'test(deploy): exercise external module packages' (#90) from test/external-module-deploy-smoke into main
CI / verify (push) Successful in 1m3s
CI / infrastructure-smoke (push) Successful in 35s
CI / migration-smoke (push) Successful in 1m54s
CI / package-release (push) Successful in 34s
CI / image-build (push) Successful in 26s
CI / deploy-smoke (same-origin) (push) Successful in 40s
CI / deploy-smoke (split) (push) Successful in 2m13s
CI / package-publication (push) Successful in 51s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/90
Reviewed-by: The_River <[email protected]>
2026-10-10 16:32:32 +00:00
ekklesia 40ef0ff802 fix(deploy): bound object storage allocation size
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 50s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 2m19s
CI / package-publication (pull_request) Successful in 52s
2026-10-10 11:07:53 -05:00
ekklesia a0e9948820 fix(storage): recognize errors across module packages
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 29s
CI / migration-smoke (pull_request) Successful in 1m44s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 53s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / deploy-smoke (split) (pull_request) Successful in 2m20s
CI / package-publication (pull_request) Successful in 51s
2026-10-10 10:54:13 -05:00
ekklesia 6e64b3c5f9 test(deploy): allow slower display uploads
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m57s
CI / package-release (pull_request) Successful in 37s
CI / image-build (pull_request) Successful in 54s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 2m21s
CI / package-publication (pull_request) Successful in 52s
2026-10-09 22:37:18 -05:00
ekklesia fa27603133 test(deploy): exercise external module packages
CI / verify (pull_request) Successful in 1m13s
CI / infrastructure-smoke (pull_request) Successful in 40s
CI / migration-smoke (pull_request) Successful in 1m56s
CI / package-release (pull_request) Successful in 37s
CI / image-build (pull_request) Successful in 54s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 2m22s
CI / package-publication (pull_request) Successful in 55s
2026-10-09 22:18:04 -05:00
The_River 9e01679fcb Merge pull request 'chore(release): prepare 0.2.3' (#89) from chore/release-0.2.3 into main
CI / verify (push) Successful in 1m8s
CI / infrastructure-smoke (push) Successful in 28s
CI / migration-smoke (push) Successful in 1m56s
CI / package-release (push) Successful in 37s
CI / image-build (push) Successful in 25s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m22s
CI / package-publication (push) Successful in 50s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/89
Reviewed-by: The_River <[email protected]>
2026-10-10 00:01:54 +00:00
ekklesia 227c92e35d chore(release): prepare 0.2.3
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 1m1s
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 55s
2026-10-09 18:38:22 -05:00
The_River 505c9e8c0a Merge pull request 'Fix native package verification on hardened noexec /tmp' (#88) from fix/verifier-exec-scratch into main
CI / verify (push) Successful in 1m9s
CI / infrastructure-smoke (push) Successful in 37s
CI / migration-smoke (push) Successful in 2m1s
CI / package-release (push) Successful in 36s
CI / image-build (push) Successful in 26s
CI / deploy-smoke (same-origin) (push) Successful in 44s
CI / deploy-smoke (split) (push) Successful in 1m27s
CI / package-publication (push) Successful in 54s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/88
Reviewed-by: The_River <[email protected]>
2026-10-09 23:36:11 +00:00
ekklesia 4be046c8d0 fix: support native package verifier scratch
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / package-release (pull_request) Successful in 36s
CI / image-build (pull_request) Successful in 52s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
CI / package-publication (pull_request) Successful in 51s
2026-10-09 18:05:04 -05:00
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
558 changed files with 77325 additions and 992 deletions
+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
+123
View File
@@ -128,6 +128,129 @@ jobs:
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
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Activate pinned pnpm
run: |
corepack enable
corepack prepare [email protected] --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
# 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
+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.zayin.net/api/packages/Zayin/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/
+71 -15
View File
@@ -6,27 +6,60 @@ contents, because restated details drift.
## What this repository is
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
monorepo:
Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built
as a strictly layered pnpm/TypeScript monorepo:
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
feature module, a surface, or an app.
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
packages. They own their own data and migrations, declare capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
primary and full-viewport display artifacts, authored in React against the same role-neutral
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
imports a feature module, a surface, or an app.
- **Modules** are independently versioned product packages. First-party modules live in **their own
repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
(ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
Module Database ABI v1 (ADR 0050).
- **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.
presentation concern a module does not. The shell **hosts** module UI through the generic loader;
it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only.
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
documented. Read `docs/architecture/repository-boundaries.md` and
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
## Product direction lives beside this repository
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into
this repository to make them available. They are authoritative for product direction: read the
relevant stages and sections before designing a change. If this repository's documentation disagrees
with them, flag the conflict, correct the repository-local documentation where appropriate, and never
silently follow the stale text. Architectural decisions still change only through ADRs (below).
Rules from those documents that every change here must respect:
- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and
devices, accounts and identity, roles and authorization, branding, mail, push, storage providers,
and module installation/lifecycle — belong to the platform, and modules consume them.
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from
installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may.
Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy.
- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light,
dark, and system modes. Normal UI colours come only from the shared design tokens
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
in product UI. Destructive actions use in-product confirmation proportional to their impact.
- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard
sidebar's *Display devices* panel) and Signage's own display roster are a transitional
implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not
build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell
redesign early, and keep existing paired displays working across any change.
## ADRs are authoritative
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
@@ -43,6 +76,7 @@ At minimum, read the current versions of:
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
- `README.md` (current platform status — what is and is not implemented)
- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above)
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
- `CONTRIBUTING.md`
@@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
## Working conventions
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
change branch-protection settings.
- The canonical repositories are on the church Gitea under the `Zayin` organization:
`ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
and branch from `origin/main`, never from whatever is checked out.
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
merge your own PR, and never change branch-protection settings.
- Never create or push a release tag. Release tags belong to the human maintainer.
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
platform logic into a module to avoid making the correct Core change.
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
burying them in an implementation.
- Add or update tests when you change a contract or architectural rule. New behavior needs new
@@ -66,6 +108,13 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
- Never run a database migration automatically (on import, on startup, in a test that isn't
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
an explicit, operator-invoked action.
- Report what you find. A defect, flaky test, documentation/ADR conflict, or CI problem outside the
task at hand gets an issue in the repository it belongs to, as soon as you find it. Search for an
existing issue first; something fixed in the same PR goes in that PR's description instead. Be
descriptive: what you saw, the evidence (run and job links, commit, file and line), how to
reproduce it, the impact, and options, and never a secret. Label it `found-by-claude` (an agent
that cannot apply labels ends the body with a line saying so), leave it unassigned, and list what
you filed when you report back.
## Canonical verification commands
@@ -78,6 +127,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
+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/>.
+74 -8
View File
@@ -44,8 +44,38 @@ selected by `EKKLESIA_MODULES_DIR` (`/var/lib/ekklesia/modules` in the image, th
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. Purge, a package format,
signing, and any installer or marketplace do not exist yet.
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`,
@@ -502,8 +532,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
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 7 after ADR 0033 added the optional display declaration and ADR 0041 added
browser contributions.
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
@@ -792,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
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,
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing
(since added; see the Stage 1 paragraph below),
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
@@ -837,7 +868,7 @@ is versioned with no dual acceptance; migrating is one `pnpm build`.
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
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
@@ -875,6 +906,19 @@ 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.
Stage 1 begins with **real Dashboard URLs**
([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The
Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own
address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark,
and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages.
The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout
each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves
the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and
discovery refuses a module that uses one. An installed module that is disabled shows a host-owned
*This page isn't available* rather than loading anything. A module's own
sub-pages, Settings and Account pages, the sidebar redesign, and returning to the workflow a Settings
link came from are later Stage 1 work.
## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
@@ -1068,5 +1112,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.
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "@ekklesia/api",
"version": "0.1.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
"files": [
+25 -11
View File
@@ -1,6 +1,5 @@
import {
isAuthenticatedPrincipal,
type ModuleRuntimeServices,
type ModuleSecretSource,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
@@ -13,6 +12,7 @@ import type { DashboardStore } from "@ekklesia/core/dashboard";
import type { DeviceStore } from "@ekklesia/core/devices";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
import type {
AnyModuleRuntimeServices,
DiscoveredModule,
ModuleConfigurationLookup,
ModuleConfigurationSettings,
@@ -20,6 +20,7 @@ import type {
ModuleLifecycleSnapshot,
ModuleLifecycleStore,
ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup,
ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules";
@@ -30,7 +31,7 @@ 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 { createDeviceRoutes, renewDeviceSessionCookie } 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";
@@ -62,11 +63,10 @@ export interface CreateApiAppOptions {
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
* empty, for the same reason as module routes: application composition never guesses what ran. */
readonly moduleRealtime: ModuleRealtimeRegistry;
/** The same frozen services object the caller's started module runtime already passed to every
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
* `ModuleDatabase` for the lifetime of this API runtime. */
readonly moduleServices: ModuleRuntimeServices;
/** The 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`
@@ -363,10 +363,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
// 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));
// The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the
// page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
// `/<module-id>`. 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, {
modulePages: options.moduleLifecycle.snapshot.discoveredModules
.filter((module) => module.entrypoints.ui !== undefined)
.map((module) => module.manifest.id),
}),
);
app.route(
"/",
@@ -407,6 +415,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
registry: options.authorization.registry,
runtime,
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
...(options.devices
? {
renewDeviceCredential: (c: Context) =>
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
}
: {}),
});
if (options.devices) {
+64 -14
View File
@@ -1,13 +1,19 @@
import { moduleIdSchema } from "@ekklesia/contracts";
import { isReservedModuleId } from "@ekklesia/core/modules";
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
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>`.
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
* serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
* this server and the shell's router read one list), and at each installed module's `/<module-id>`.
* Every one of those first segments is either a reserved platform name or a module id Core accepted,
* and Core refuses a module id that is a reserved name, so no module page can shadow `/api`,
* `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display
* host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still
* lands on `/` with a query (ADR 0038).
*/
export const DASHBOARD_HOST_PAGE_PATH = "/";
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
@@ -85,30 +91,74 @@ function fixedAsset(
});
}
export interface DashboardHostRouteOptions {
/**
* The id of every module this process discovered that declares a Dashboard UI — enabled or not,
* since a disabled module's bookmark should open the Dashboard and say the module is unavailable
* rather than fail as an unknown address. Fixed at startup, like the rest of the module set.
*/
readonly modulePages: readonly string[];
}
/** Each module page must be a real module id that names no platform namespace, exactly once.
* Discovery already guarantees this; the routes refuse to compose rather than trust it. */
function validatedModulePages(modulePages: readonly string[]): readonly string[] {
const seen = new Set<string>();
for (const moduleId of modulePages) {
if (
!moduleIdSchema.safeParse(moduleId).success ||
isReservedModuleId(moduleId) ||
DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) ||
seen.has(moduleId)
) {
throw new DashboardHostConfigurationError();
}
seen.add(moduleId);
}
return [...seen];
}
/**
* 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.
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
* from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
* platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
* module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
* server change later) — always with the **same** bytes and the same validator, because the path
* selects a page inside the shell rather than a file here; that is what lets a refreshed or
* bookmarked deep link stay where it was.
*
* A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not
* installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does
* `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall 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 {
export function createDashboardHostRoutes(
assets: DashboardHostAssets,
options: DashboardHostRouteOptions,
): Hono {
const snapshot = snapshotAssets(assets);
const modulePages = validatedModulePages(options.modulePages);
const routes = new Hono();
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
const page = (ifNoneMatch: string | undefined): Response =>
fixedAsset(
c.req.header("if-none-match"),
ifNoneMatch,
snapshot.html,
snapshot.htmlEtag,
"text/html; charset=utf-8",
DASHBOARD_PAGE_SECURITY_HEADERS,
),
);
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
routes.get(`/${name}`, (c) => page(c.req.header("if-none-match")));
routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match")));
}
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
fixedAsset(
c.req.header("if-none-match"),
+6 -1
View File
@@ -1,4 +1,4 @@
import type { ModuleDatabase } from "@ekklesia/contracts";
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
import {
type AuthorizationDrizzleDatabase,
createAuthorizationDrizzleView,
@@ -12,9 +12,11 @@ import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/c
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
import {
createModuleConfigurationView,
createModuleDatabaseExecutor,
createModuleDatabaseView,
createModuleLifecycleView,
type ModuleConfigurationDrizzleDatabase,
type ModuleDatabase,
type ModuleLifecycleDrizzleDatabase,
} from "@ekklesia/core/modules";
@@ -24,6 +26,8 @@ export interface ApiDatabaseHandle {
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;
/**
@@ -76,6 +80,7 @@ export function createApiDatabase(
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) => {
+22 -1
View File
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
*/
function writeDeviceCookie(
c: Context,
options: DeviceRoutesOptions,
options: Pick<DeviceRoutesOptions, "secureCookies">,
name: string,
value: string,
maxAgeSeconds: number,
@@ -106,6 +106,27 @@ function writeDeviceCookie(
});
}
/**
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
*
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
*/
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
if (credential === undefined || credential.length === 0) {
return;
}
writeDeviceCookie(
c,
{ secureCookies },
DEVICE_SESSION_COOKIE,
credential,
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
);
}
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
const routes = new HonoApp();
const now = options.now ?? ((): Date => new Date());
+8
View File
@@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
/** 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;
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
* deployment has device support; called only for a request that resolved to a device. */
readonly renewDeviceCredential?: (c: Context) => void;
}
/** A composition error, detected before any display route is registered. Carries no module id,
@@ -199,6 +202,11 @@ function mountDisplay(
if (!viewer) {
return displayFailure(c);
}
// Every screen asks for this on start, on reconnect, and periodically while running, so it
// is where an in-use credential's browser expiry is pushed forward.
if (principal.kind === "device") {
options.renewDeviceCredential?.(c);
}
return c.json(await describeDisplay(options, principal, display, viewer), 200);
} catch {
return displayFailure(c);
+20 -7
View File
@@ -1,14 +1,17 @@
import {
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleDatabaseExecutor,
type ModuleId,
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import type {
AnyModuleRuntimeServices,
ModuleConfigurationLookup,
ModuleDatabase,
ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
@@ -37,14 +40,14 @@ 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.
*
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
* 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;
@@ -232,6 +235,11 @@ 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
@@ -243,19 +251,24 @@ export function mountModuleHttpRoutes(
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),
realtime: options.realtime.publisherFor(route.moduleId),
services: options.services,
});
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);
}
+15 -13
View File
@@ -1,11 +1,7 @@
import type { Socket } from "node:net";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
EMPTY_MODULE_SECRET_SOURCE,
type ModuleRuntimeServices,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts";
import {
createAuthorizationEvaluator,
createAuthorizationStore,
@@ -21,12 +17,14 @@ import {
} 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,
@@ -393,16 +391,13 @@ 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.
* 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 it the same services object every module's
* `start`/`stop` hook receives.
* 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 exact same services object to every module HTTP
* handler.
* 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
@@ -459,12 +454,10 @@ export async function startApi(
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
let services: ModuleRuntimeServices;
let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
let deviceStore: ReturnType<typeof createDeviceStore>;
try {
services = Object.freeze({ database: databaseHandle.module });
runtime = createIdentityRuntime({
config: config.identity,
db: databaseHandle.identity,
@@ -532,6 +525,15 @@ export async function startApi(
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(
+38 -15
View File
@@ -208,6 +208,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
}
}
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
const SHUTDOWN_SETTLE_MS = 3_000;
/**
* The connections left on `databaseName` once a shutdown has had a moment to land.
*
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
*/
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
for (;;) {
const count = await activeConnectionCount(databaseName);
if (count === 0 || Date.now() >= deadline) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
@@ -221,9 +244,9 @@ 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",
},
});
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
@@ -339,10 +362,10 @@ async function createGatedRouteModule(
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/server/index.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
version: "0.1.0",
@@ -386,11 +409,11 @@ async function createFixtureModule(
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 7,
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,
@@ -521,10 +544,10 @@ async function createUiModule(
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
version,
}),
@@ -1349,7 +1372,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
await running.shutdown();
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
await dropDatabase(database.name);
@@ -2230,7 +2253,7 @@ describe.sequential("live API — platform owner and delegated grant management"
it("shuts down cleanly, closing the one shared pool", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
await dropDatabase(database.name);
});
@@ -2592,9 +2615,9 @@ describe.sequential("live API — installed module permission end-to-end", () =>
const report = await diagnoseEkklesia({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.0",
},
runtimeRange: ">=0.0.0",
runtimeVersion: process.versions.node,
@@ -2837,7 +2860,7 @@ describe.sequential("live API — installed module permission end-to-end", () =>
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
// Exactly one stop, matching the exactly-one register/start already asserted above.
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
@@ -3906,9 +3929,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.0",
},
});
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
+4 -4
View File
@@ -1,8 +1,6 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
moduleIdSchema,
parseEkklesiaUserId,
parsePermissionId,
@@ -20,6 +18,8 @@ import { IdentityRuntimeError } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleUiPublicationRegistry,
StagedModuleRealtimeRegistry,
} from "@ekklesia/core/modules";
@@ -48,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(),
+220 -6
View File
@@ -1,9 +1,8 @@
import { createHash } from "node:crypto";
import {
ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRouteHandler,
type ModuleRuntimeServices,
parseModuleManifest,
parsePermissionId,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
@@ -15,7 +14,13 @@ import {
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
EMPTY_MODULE_REALTIME_REGISTRY,
isReservedModuleId,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleLifecycleConfiguration,
RESERVED_MODULE_IDS,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
@@ -33,11 +38,23 @@ import {
DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
DashboardHostConfigurationError,
createDashboardHostRoutes,
} from "../src/dashboard-host-routes.js";
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
import {
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
MODULE_DISPLAY_PAGE_PREFIX,
} from "../src/module-display-path.js";
import {
DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_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 {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
@@ -74,13 +91,44 @@ function fakeStore(): AuthorizationStore {
};
}
function fakeModuleServices(): ModuleRuntimeServices {
function fakeModuleServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id
* and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */
function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: {
server: "./dist/server/index.js",
...(options.ui ? { ui: "./dist/ui/index.js" } : {}),
},
id,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
version: "0.1.0",
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: {
server: `/modules/${id}/dist/server/index.js`,
...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}),
},
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
function buildApp(
options: {
readonly assets?: DashboardHostAssets;
readonly configuration?: ModuleLifecycleConfiguration;
readonly discovered?: readonly DiscoveredModule[];
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
} = {},
) {
@@ -100,7 +148,10 @@ function buildApp(
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: fakeModuleServices(),
moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
@@ -193,6 +244,169 @@ describe("GET / (the Dashboard page)", () => {
});
});
describe("Dashboard deep links (ADR 0057)", () => {
const installed = () => [
discoveredModule("signage", { ui: true }),
discoveredModule("calendar", { ui: true }),
discoveredModule("retired", { ui: true }),
// A display-only module has no Dashboard page (ADR 0033).
discoveredModule("lobby-screen", { ui: false }),
];
// Installed but disabled: its address still opens the Dashboard, which says it is unavailable.
const configuration = { modules: [{ enabled: false, moduleId: "retired" }] };
const deepLinks = [
"/settings",
"/settings/",
"/settings/displays/add",
"/account",
"/account/appearance",
"/signage",
"/signage/",
// Reserved for the module's own pages, which the shell will interpret (ADR 0057).
"/signage/timelines",
"/signage/timelines/a1b2",
"/calendar",
"/retired",
];
it("serves the shell's own platform pages, from the shell's own list", () => {
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
});
it("serves the identical page, validator, and security headers at every Dashboard page, so a refresh stays put", async () => {
const { app } = buildApp({ configuration, discovered: installed() });
for (const path of deepLinks) {
const response = await app.request(path);
expect(response.status, path).toBe(200);
expect(await response.text(), path).toBe(PAGE_HTML);
expect(response.headers.get("content-type"), path).toBe("text/html; charset=utf-8");
expect(response.headers.get("cache-control"), path).toBe("no-cache");
expect(response.headers.get("etag"), path).toBe(PAGE_ETAG);
expectPageSecurityHeaders(response);
}
expect(readFileMock).not.toHaveBeenCalled();
});
it("revalidates a deep link with the page's one validator", async () => {
const { app } = buildApp({ discovered: installed() });
for (const path of ["/settings/displays", "/signage"]) {
const response = await app.request(path, { headers: { "If-None-Match": PAGE_ETAG } });
expect(response.status, path).toBe(304);
expect(await response.text(), path).toBe("");
expectPageSecurityHeaders(response);
}
});
it("never turns an arbitrary top-level path into a Dashboard page", async () => {
const { app } = buildApp({ configuration, discovered: installed() });
for (const path of [
"/definitely-not-a-real-module",
"/definitely-not-a-real-module/timelines",
// Installed, but with no Dashboard UI.
"/lobby-screen",
// Look-alikes of real pages.
"/signages",
"/signage-old",
"/Signage",
"/settings-old",
"/accounts",
// The former implementation prefix is not a namespace at all.
"/modules",
"/modules/signage",
]) {
await expectSanitizedNotFound(await app.request(path), path);
}
});
it("serves a module's page only while that module is installed", async () => {
const { app } = buildApp({ discovered: [] });
await expectSanitizedNotFound(await app.request("/signage"), "/signage");
expect((await app.request("/settings")).status).toBe(200);
});
it("serves the page and never the script, whatever a deep link's last segment looks like", async () => {
const { app } = buildApp({ discovered: installed() });
for (const path of [
"/signage/app.js",
"/settings/assets/ekklesia-dashboard/app.js",
"/account/index.html",
]) {
expect(await (await app.request(path)).text(), path).toBe(PAGE_HTML);
}
});
it("answers no other method at a Dashboard page", async () => {
const { app } = buildApp({ discovered: installed() });
for (const path of ["/settings/displays", "/signage"]) {
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
}
}
});
it("refuses at composition a module page that would shadow a platform namespace", () => {
for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) {
expect(
() =>
createDashboardHostRoutes(createDashboardHostAssets(PAGE_HTML, SCRIPT), { modulePages }),
JSON.stringify(modulePages),
).toThrow(DashboardHostConfigurationError);
}
});
});
describe("the platform's reserved top-level names (ADR 0057)", () => {
it("cover every top-level namespace the API registers, so a module page can never shadow one", () => {
const { app } = buildApp({ discovered: [discoveredModule("signage", { ui: true })] });
const topLevel = new Set(
app.routes
.map((route) => route.path.split("/")[1] ?? "")
.filter((segment) => segment !== "" && segment !== "*"),
);
topLevel.delete("signage");
// This composition publishes no display, so the display host's routes are absent here; they
// are checked through their own path constants below.
expect([...topLevel].sort()).toEqual(["account", "api", "assets", "settings"]);
for (const segment of topLevel) {
expect(isReservedModuleId(segment), segment).toBe(true);
}
});
it("cover the display host's pages, service worker, and script, and the Dashboard's script", () => {
for (const path of [
MODULE_DISPLAY_PAGE_PREFIX,
DISPLAY_SERVICE_WORKER_PATH,
DISPLAY_HOST_SCRIPT_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
]) {
expect(isReservedModuleId(path.split("/")[1] ?? ""), path).toBe(true);
}
expect([...RESERVED_MODULE_IDS].sort()).toEqual([
"account",
"api",
"assets",
"display",
"settings",
]);
});
it("include every Dashboard platform page the shell interprets", () => {
for (const page of DASHBOARD_PLATFORM_PAGES) {
expect(RESERVED_MODULE_IDS, page).toContain(page);
}
});
});
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();
+4 -4
View File
@@ -5,8 +5,6 @@ import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -20,6 +18,8 @@ import {
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";
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
},
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: fixture.name,
version: "1.0.0",
}),
@@ -147,7 +147,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
}) as LegacyModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
+123 -2
View File
@@ -118,13 +118,13 @@ async function createDisplayModuleDirectory(): Promise<string> {
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
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: 7,
manifestVersion: 8,
name: "Announcements fixture",
permissions: [
{
@@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => {
}
});
/**
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
*/
describe("credential lifetime", () => {
const ttlSeconds = 180 * 24 * 60 * 60;
const descriptor = "/api/v1/module-displays/announcements";
function renewal(response: Response): string | undefined {
return setCookieFor(response, "ekklesia_device_session");
}
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
const running = await platform("renewal");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
);
const response = await device.request(descriptor);
expect(response.status).toBe(200);
// The same credential, re-issued with a full lifetime and the same protections.
const header = renewal(response);
expect(header).toBeDefined();
expect(header).toContain(`ekklesia_device_session=${credential};`);
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
expect(header).toMatch(/HttpOnly/u);
expect(header).toMatch(/SameSite=Strict/u);
expect(header).toMatch(/Path=\//u);
expect(header).not.toMatch(/Domain=/iu);
// Using it moved the server's window forward too.
const [row] = await query<{ recent: boolean }>(
running.url,
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
);
expect(row?.recent).toBe(true);
} finally {
await running.close();
}
});
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
const running = await platform("legacy");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
// A production screen paired 179 days ago that checked in this morning: under the old
// rule it had one day left. It must carry straight on.
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
);
expect((await device.request(descriptor)).status).toBe(200);
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
);
expect((await device.request(descriptor)).status).toBe(200);
} finally {
await running.close();
}
});
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
const running = await platform("abandoned");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
);
const response = await device.request(descriptor);
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
expect(response.status).toBe(403);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
it("never renews a revoked screen", async () => {
const running = await platform("revoked-renewal");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
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`,
{},
);
const response = await device.request(descriptor);
expect(response.status).toBe(403);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
it("does not hand a device cookie to a person viewing the display", async () => {
const running = await platform("human-renewal");
try {
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
expect(response.status).toBe(200);
expect(renewal(response)).toBeUndefined();
} 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);
+3 -3
View File
@@ -1,8 +1,6 @@
import {
ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRouteHandler,
type ModuleRuntimeServices,
parsePermissionId,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
@@ -15,6 +13,8 @@ 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";
@@ -56,7 +56,7 @@ function fakeStore(): AuthorizationStore {
};
}
function fakeModuleServices(): ModuleRuntimeServices {
function fakeModuleServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
+6 -6
View File
@@ -104,11 +104,11 @@ async function createFixtureModule(
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 7,
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,
@@ -267,10 +267,10 @@ 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: 7,
manifestVersion: 8,
name: `${id} module`,
permissions,
version: "0.1.0",
@@ -1435,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();
@@ -6,8 +6,6 @@ import {
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleConfigurationDeclarationInput,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -22,10 +20,12 @@ import {
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";
@@ -108,7 +108,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
configuration: fixture.configuration ?? [],
entrypoints: { server: "./dist/server/index.js" },
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
version: "1.0.0",
}),
@@ -213,7 +213,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
}) as LegacyModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
runtime,
trustedOrigins: [trustedOrigin],
+34 -5
View File
@@ -8,6 +8,7 @@ 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";
@@ -194,6 +195,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
}
}
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
const SHUTDOWN_SETTLE_MS = 3_000;
/**
* The connections left on `databaseName` once a shutdown has had a moment to land.
*
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
*/
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
for (;;) {
const count = await activeConnectionCount(databaseName);
if (count === 0 || Date.now() >= deadline) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
@@ -211,9 +235,9 @@ 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([
@@ -240,9 +264,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.
@@ -441,6 +465,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);
@@ -754,7 +783,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
const eventsAfterShutdown = readRecorder();
+4 -4
View File
@@ -5,8 +5,6 @@ import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -21,6 +19,8 @@ import {
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";
@@ -105,7 +105,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
: {}),
},
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions: fixture.display
? [
@@ -155,7 +155,7 @@ interface Installation {
readonly modules: readonly DiscoveredModule[];
}
function fakeServices(): ModuleRuntimeServices {
function fakeServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
+46 -15
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 {
@@ -20,20 +18,23 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleRuntimeServicesLookup,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import {
canonicalizeVaryHeader,
mergedVaryHeader,
ModuleRouteConfigurationError,
mergedVaryHeader,
} from "../src/module-http-routes.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.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";
@@ -73,11 +74,11 @@ function moduleRoute(
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(),
@@ -91,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();
@@ -107,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 ?? {
@@ -145,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({
@@ -508,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 () => {
@@ -637,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 () => {
@@ -766,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 () => {
@@ -5,8 +5,6 @@ import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -20,6 +18,8 @@ import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleLifecycleStore,
ModuleLifecycleStoreError,
EMPTY_MODULE_REALTIME_REGISTRY,
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
version: "1.0.0",
}),
@@ -189,7 +189,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
}) as LegacyModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
runtime,
trustedOrigins: [trustedOrigin],
+4 -4
View File
@@ -4,9 +4,7 @@ import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRealtimePublisher,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -18,6 +16,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
discoverModules,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleRealtimeRegistry,
type ModuleRealtimeSubscription,
startDiscoveredModuleRuntime,
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" },
id: "announcements",
manifestVersion: 7,
manifestVersion: 8,
name: "Announcements fixture",
permissions: [
{
@@ -226,7 +226,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
return { directory, symbol };
}
function services(): ModuleRuntimeServices {
function services(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
}
+4 -4
View File
@@ -5,8 +5,6 @@ import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -18,6 +16,8 @@ import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModules,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules";
import {
@@ -129,7 +129,7 @@ async function createFixtureModule(
: {}),
},
id,
manifestVersion: 7,
manifestVersion: 8,
name,
permissions: [
{
@@ -241,7 +241,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
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
+4 -4
View File
@@ -5,8 +5,6 @@ import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -21,6 +19,8 @@ import {
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";
@@ -99,7 +99,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: "1.0.0",
})),
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({
...permission,
@@ -192,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);
+5 -7
View File
@@ -1,11 +1,7 @@
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 { ANONYMOUS_PRINCIPAL } from "@ekklesia/contracts";
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
@@ -14,6 +10,8 @@ import {
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";
@@ -139,7 +137,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: "1.0.0",
})),
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
permissions:
fixture.display === undefined
@@ -215,7 +213,7 @@ async function install(
: 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
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "@ekklesia/core",
"version": "0.1.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
"files": [
+188
View File
@@ -0,0 +1,188 @@
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 const MODULE_VERIFIER_SCRATCH_ENV = "EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR";
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();
}
/** The CLI's operator-facing bridge to Core's otherwise explicit verifier option. */
export function moduleVerifierScratchDirectory(environment: NodeJS.ProcessEnv): string | undefined {
const configured = environment[MODULE_VERIFIER_SCRATCH_ENV];
if (configured === undefined) return undefined;
if (configured === "" || !path.isAbsolute(configured)) {
throw new VerifyArgumentsError(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
}
return configured;
}
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));
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
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,
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
...(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;
});
}
+18 -8
View File
@@ -44,17 +44,26 @@ export class DeviceStoreError extends Error {
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
/**
* How long a device credential is accepted before the device must pair again.
* How long a device credential may go **unused** before the device must pair again (ADR 0056).
*
* 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.
* The window slides: every request from the device moves `lastSeenAt` forward, and the host
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops
* being an identity at all: 180 days after it was last seen, its credential is dead.
*/
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
/** When a device credential expires: the idle window, measured from the last time the device was
* seen, or from pairing if it never has been. */
export function deviceCredentialExpiresAt(input: {
readonly createdAt: Date;
readonly lastSeenAt?: Date | null | undefined;
}): Date {
const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0);
return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS);
}
/** 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;
@@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
}
// 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()) {
// Expiry is measured from last use, not from pairing, so a screen in use never ages out.
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
return undefined;
}
return Object.freeze({
+349
View File
@@ -0,0 +1,349 @@
import {
type ModuleDatabaseField,
ModuleDatabaseError,
type ModuleDatabaseValue,
} from "@ekklesia/contracts";
/**
* The value contract of Module Database ABI v1 (ADR 0050): how parameters are encoded before they
* reach PostgreSQL, and how result text is decoded after. Both are Core's, and neither consults
* `pg`'s process-global type registry or any ORM, which module code could change or differ from.
*
* Every value travels in PostgreSQL's text format. The session settings the decoding depends on
* (`DateStyle`, `IntervalStyle`, `TimeZone`, `bytea_output`, `extra_float_digits`) are pinned by the
* executor for every ABI operation (`./database-abi.ts`).
*/
/** PostgreSQL's limit on bound parameters in one statement (the protocol's 16-bit count). */
export const MODULE_DATABASE_MAX_PARAMETERS = 65_535;
/** PostgreSQL's limit on array dimensions (`MAXDIM`). */
const maximumArrayDimensions = 6;
function parameterInvalid(): ModuleDatabaseError {
return new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID");
}
function isScalar(value: unknown): boolean {
return (
value === null ||
typeof value === "boolean" ||
typeof value === "number" ||
typeof value === "bigint" ||
typeof value === "string" ||
value instanceof Uint8Array
);
}
function scalarText(value: boolean | number | bigint | string): string {
// `String` spells NaN, Infinity, and -Infinity exactly as PostgreSQL's float and numeric input
// accepts them.
return typeof value === "string" ? value : String(value);
}
function byteaText(value: Uint8Array): string {
return `\\x${Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("hex")}`;
}
function quoteArrayElement(text: string): string {
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
}
/**
* Validates one array level and returns its PostgreSQL array literal. Every element of a level is
* either a sub-array or a scalar, and sibling sub-arrays have equal lengths, because PostgreSQL
* arrays are rectangular. Every non-null element is quoted, which PostgreSQL accepts for any element
* type.
*/
function arrayLiteral(values: readonly unknown[], depth: number): string {
if (depth > maximumArrayDimensions) {
throw parameterInvalid();
}
const elements: string[] = [];
let nestedLength: number | undefined;
let nested: boolean | undefined;
for (let index = 0; index < values.length; index += 1) {
if (!Object.hasOwn(values, index)) {
throw parameterInvalid();
}
const element = values[index];
const isNested = Array.isArray(element);
if (nested === undefined) {
nested = isNested;
} else if (nested !== isNested) {
throw parameterInvalid();
}
if (isNested) {
if (nestedLength === undefined) {
nestedLength = element.length;
} else if (nestedLength !== element.length) {
throw parameterInvalid();
}
elements.push(arrayLiteral(element, depth + 1));
} else if (element === null) {
elements.push("NULL");
} else if (element instanceof Uint8Array) {
elements.push(quoteArrayElement(byteaText(element)));
} else if (isScalar(element)) {
elements.push(quoteArrayElement(scalarText(element as boolean | number | bigint | string)));
} else {
throw parameterInvalid();
}
}
return `{${elements.join(",")}}`;
}
/**
* Validates `values` against the ABI's parameter set and encodes each one into the only forms the
* driver then passes through unchanged: `null`, a string, or a `Buffer` copy of a `Uint8Array`.
* Anything else, including `undefined`, `Date`, plain objects, other class instances, functions,
* symbols, sparse or ragged arrays, and arrays deeper than PostgreSQL allows, is refused before
* anything reaches PostgreSQL. The error never names the value.
*/
export function encodeModuleDatabaseParameters(values: unknown): (string | Buffer | null)[] {
if (values === undefined) {
return [];
}
if (!Array.isArray(values) || values.length > MODULE_DATABASE_MAX_PARAMETERS) {
throw parameterInvalid();
}
const encoded: (string | Buffer | null)[] = [];
for (let index = 0; index < values.length; index += 1) {
if (!Object.hasOwn(values, index)) {
throw parameterInvalid();
}
const value: unknown = values[index];
if (value === null) {
encoded.push(null);
} else if (value instanceof Uint8Array) {
// A copy: the driver serializes when the statement is sent, which may be after the caller
// has changed its own bytes.
encoded.push(Buffer.from(value));
} else if (Array.isArray(value)) {
encoded.push(arrayLiteral(value, 1));
} else if (isScalar(value)) {
encoded.push(scalarText(value as boolean | number | bigint | string));
} else {
throw parameterInvalid();
}
}
return encoded;
}
// --- Results -----------------------------------------------------------------------------------
type ScalarDecoder = (text: string) => ModuleDatabaseValue;
const asText: ScalarDecoder = (text) => text;
const asBoolean: ScalarDecoder = (text) => text === "t";
const asInteger: ScalarDecoder = (text) => Number.parseInt(text, 10);
// `Number` reads PostgreSQL's NaN, Infinity, -Infinity, and exponent forms. With
// `extra_float_digits` pinned to 1 the text is the shortest form that round-trips exactly.
const asFloat: ScalarDecoder = (text) => Number(text);
function hexDigit(code: number): number {
if (code >= 48 && code <= 57) {
return code - 48;
}
if (code >= 97 && code <= 102) {
return code - 87;
}
if (code >= 65 && code <= 70) {
return code - 55;
}
return -1;
}
/**
* `bytea` text to bytes. The executor pins `bytea_output` to `hex`; the older `escape` form is read
* too, so a transaction that changed the setting still decodes faithfully rather than failing.
* Always a plain `Uint8Array`, never a `Buffer`.
*/
const asBytes: ScalarDecoder = (text) => {
if (text.startsWith("\\x")) {
const length = (text.length - 2) / 2;
const bytes = new Uint8Array(length);
for (let index = 0; index < length; index += 1) {
const high = hexDigit(text.charCodeAt(2 + index * 2));
const low = hexDigit(text.charCodeAt(3 + index * 2));
bytes[index] = high * 16 + low;
}
return bytes;
}
const bytes: number[] = [];
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
if (code === 92 && text.charCodeAt(index + 1) === 92) {
bytes.push(92);
index += 1;
} else if (code === 92) {
bytes.push(Number.parseInt(text.slice(index + 1, index + 4), 8));
index += 3;
} else {
bytes.push(code);
}
}
return Uint8Array.from(bytes);
};
/** Built-in scalar type OIDs with a decoding other than "PostgreSQL's text output, unchanged". Every
* type not listed here, `int8`, `numeric`, date and time types, `json`, and `jsonb` included,
* arrives as its exact text. */
const scalarDecoders: ReadonlyMap<number, ScalarDecoder> = new Map([
[16, asBoolean], // bool
[17, asBytes], // bytea
[21, asInteger], // int2
[23, asInteger], // int4
[26, asInteger], // oid
[700, asFloat], // float4
[701, asFloat], // float8
]);
/** Built-in array type OIDs, each with its element type OID. An array of a type not listed here
* (an enum, a domain, an extension type) arrives as its text, like any other type. */
const arrayElementTypes: ReadonlyMap<number, number> = new Map([
[1000, 16], // bool[]
[1001, 17], // bytea[]
[1002, 18], // "char"[]
[1003, 19], // name[]
[1005, 21], // int2[]
[1007, 23], // int4[]
[1009, 25], // text[]
[1014, 1042], // bpchar[]
[1015, 1043], // varchar[]
[1016, 20], // int8[]
[1021, 700], // float4[]
[1022, 701], // float8[]
[1028, 26], // oid[]
[1115, 1114], // timestamp[]
[1182, 1082], // date[]
[1183, 1083], // time[]
[1185, 1184], // timestamptz[]
[1187, 1186], // interval[]
[1231, 1700], // numeric[]
[1270, 1266], // timetz[]
[2951, 2950], // uuid[]
[199, 114], // json[]
[3807, 3802], // jsonb[]
]);
class ArrayTextError extends Error {}
/**
* Parses PostgreSQL's array text output into nested arrays, each element decoded by `decode`.
* Unquoted `NULL` is null; a quoted `"NULL"` is the string. A non-default lower bound
* (`[0:1]={…}`) is not represented: a JavaScript array is a zero-based list. Every element type this
* decodes uses `,` as its delimiter.
*/
export function parsePostgresArrayText(text: string, decode: ScalarDecoder): ModuleDatabaseValue[] {
let position = 0;
if (text.startsWith("[")) {
const equals = text.indexOf("=");
if (equals === -1) {
throw new ArrayTextError();
}
position = equals + 1;
}
function parseArray(): ModuleDatabaseValue[] {
if (text[position] !== "{") {
throw new ArrayTextError();
}
position += 1;
const items: ModuleDatabaseValue[] = [];
if (text[position] === "}") {
position += 1;
return items;
}
for (;;) {
const character = text[position];
if (character === "{") {
items.push(parseArray());
} else if (character === '"') {
position += 1;
let element = "";
for (;;) {
const next = text[position];
if (next === undefined) {
throw new ArrayTextError();
}
if (next === "\\") {
element += text[position + 1] ?? "";
position += 2;
} else if (next === '"') {
position += 1;
break;
} else {
element += next;
position += 1;
}
}
items.push(decode(element));
} else {
const start = position;
while (position < text.length && text[position] !== "," && text[position] !== "}") {
position += 1;
}
const element = text.slice(start, position);
items.push(element.toUpperCase() === "NULL" ? null : decode(element));
}
if (text[position] === ",") {
position += 1;
} else if (text[position] === "}") {
position += 1;
return items;
} else {
throw new ArrayTextError();
}
}
}
const parsed = parseArray();
if (position !== text.length) {
throw new ArrayTextError();
}
return parsed;
}
function scalarDecoder(typeOid: number): ScalarDecoder {
return scalarDecoders.get(typeOid) ?? asText;
}
/** Decodes one non-null column value from its PostgreSQL text by the column's type OID. */
export function decodeModuleDatabaseValue(typeOid: number, text: string): ModuleDatabaseValue {
const elementType = arrayElementTypes.get(typeOid);
if (elementType === undefined) {
return scalarDecoder(typeOid)(text);
}
try {
return parsePostgresArrayText(text, scalarDecoder(elementType));
} catch {
// PostgreSQL produced this text, so it cannot be malformed; should it ever be, the column still
// arrives faithfully, as text, rather than as a guess.
return text;
}
}
/**
* The `types` object handed to `pg` for every ABI statement. Its parser returns the raw text for
* every type, so the driver's global registry, which module code could change, is never consulted
* and the ABI decodes from text itself. It is an empty array as well because the driver also reads
* the same option as the Parse message's parameter-type list: empty, so PostgreSQL infers every
* parameter type from the statement.
*/
export const rawTextTypes: { getTypeParser: () => (text: string) => string } & never[] =
Object.freeze(
Object.assign([] as never[], {
getTypeParser: () => (text: string) => text,
}),
);
/** Decodes one positional row of raw driver text. */
export function decodeModuleDatabaseRow(
fields: readonly ModuleDatabaseField[],
raw: readonly unknown[],
): ModuleDatabaseValue[] {
return fields.map((field, index) => {
const value = raw[index];
return typeof value === "string" ? decodeModuleDatabaseValue(field.typeOid, value) : null;
});
}
+596
View File
@@ -0,0 +1,596 @@
import {
MODULE_DATABASE_ABI_VERSION,
type ModuleDatabaseArrayRow,
ModuleDatabaseError,
type ModuleDatabaseErrorCode,
type ModuleDatabaseExecutor,
type ModuleDatabaseField,
type ModuleDatabaseObjectRow,
type ModuleDatabaseQueryable,
type ModuleDatabaseQueryResult,
type ModuleDatabaseTransaction,
type ModuleId,
moduleIdSchema,
} from "@ekklesia/contracts";
import { DatabaseError, type Pool, type PoolClient, type QueryArrayConfig } from "pg";
import {
decodeModuleDatabaseRow,
encodeModuleDatabaseParameters,
rawTextTypes,
} from "./database-abi-values.js";
/**
* Core's Module Database ABI v1 executor (ADR 0050).
*
* A module hands Core SQL text, ordered parameter values, and a row mode; Core hands back decoded
* rows, column metadata, and stable errors. No ORM, driver, pool, client, or connection object ever
* crosses in either direction. The executor is built over the one pool the API already owns
* (ADR 0016) and never closes it.
*
* This is a correctness and compatibility boundary, **not a security sandbox**: a trusted module can
* still send any SQL its database role permits. Nothing here inspects table names.
*
* ### Connections and session state
*
* Every ABI operation, a top-level statement or a whole transaction, borrows a connection from the
* pool, pins the session settings the ABI's decoding depends on, does its work, and resets the
* session with `DISCARD ALL` before returning the connection. So no setting, temporary table,
* listener, advisory lock, prepared statement, or role change a module makes survives the operation
* that made it, and nothing the ABI pinned reaches the pool's other users. A connection that cannot
* be reset cleanly, or that is not idle when the operation ends, is destroyed rather than returned.
* `DISCARD ALL` also deallocates named prepared statements; Core uses none on the shared pool.
*
* ### One statement per `query`
*
* Every ABI statement is sent with PostgreSQL's extended query protocol, forced with the driver's
* `queryMode: "extended"` even when there are no parameters. PostgreSQL itself refuses a Parse
* message holding more than one statement (SQLSTATE 42601), using its own SQL grammar, so comments,
* quoted literals, and semicolons inside them are never mistaken for a second statement. Core does
* not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports
* it empty.
*
* ### Transaction control belongs to the callbacks
*
* Transaction-control SQL (`BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`,
* `RELEASE SAVEPOINT`, `PREPARE TRANSACTION`, and their aliases) is forbidden through `query`: a
* conformance rule for trusted module code, not a security guarantee. Core cannot refuse such a
* statement beforehand without parsing SQL, which it does not do, and it cannot undo one PostgreSQL
* has already executed: a `COMMIT` sent through a transaction's `query` has committed by the time
* Core sees it. What Core does is detect the violation afterwards, from what PostgreSQL itself
* reports (the statement's command tag, and the protocol's transaction status), and fail safely:
* the call rejects, an enclosing transaction is never committed by Core and rejects too, and the
* connection is destroyed rather than returned to the pool. The commit and rollback guarantees of
* the transaction API apply to conforming use.
*/
/** The settings the ABI's decoding and determinism depend on, pinned for every ABI operation. */
const pinSessionStatement =
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; " +
"SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
const resetSessionStatement = "DISCARD ALL";
const transactionControlCommands: ReadonlySet<string> = new Set([
"BEGIN",
"COMMIT",
"PREPARE",
"RELEASE",
"ROLLBACK",
"SAVEPOINT",
"START",
]);
const isolationStatements: ReadonlyMap<unknown, string> = new Map([
["read committed", "READ COMMITTED"],
["repeatable read", "REPEATABLE READ"],
["serializable", "SERIALIZABLE"],
]);
/** `pg` 8.22 reports the protocol's ReadyForQuery transaction status through this method, which
* its type declarations omit. `acquire` refuses a client without it rather than guessing. */
type AbiClient = PoolClient & { getTransactionStatus(): string | null };
interface PreparedQuery {
readonly config: QueryArrayConfig;
readonly rowMode: "array" | "object";
}
interface RawResult {
readonly command: string | null;
readonly fields: readonly { readonly dataTypeID: number; readonly name: string }[];
readonly rowCount: number | null;
readonly rows: readonly unknown[][];
}
function databaseError(code: ModuleDatabaseErrorCode, cause?: unknown): ModuleDatabaseError {
// Only PostgreSQL's own SQLSTATE survives. The driver's message, detail, hint, position, and
// object names, and every non-PostgreSQL error, are dropped here.
if (cause instanceof DatabaseError && typeof cause.code === "string") {
return new ModuleDatabaseError(code, { sqlState: cause.code });
}
return new ModuleDatabaseError(code);
}
/** A PostgreSQL error leaves the connection usable; anything else (a dropped connection, a
* client-side timeout) may leave it mid-protocol, so it must be destroyed. */
function connectionSurvives(error: unknown): boolean {
return error instanceof DatabaseError;
}
function prepareQuery(request: unknown): PreparedQuery {
if (typeof request !== "object" || request === null || Array.isArray(request)) {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
for (const key of Object.keys(request)) {
if (key !== "text" && key !== "values" && key !== "rowMode") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
}
const { rowMode, text, values } = request as Record<string, unknown>;
if (typeof text !== "string" || text.length === 0) {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
if (rowMode !== undefined && rowMode !== "object" && rowMode !== "array") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
const config = {
queryMode: "extended",
rowMode: "array",
text,
types: rawTextTypes,
values: encodeModuleDatabaseParameters(values),
};
return { config: config as unknown as QueryArrayConfig, rowMode: rowMode ?? "object" };
}
function beginStatement(options: unknown): string {
if (options === undefined) {
return "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE";
}
if (typeof options !== "object" || options === null || Array.isArray(options)) {
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
}
for (const key of Object.keys(options)) {
if (key !== "isolation" && key !== "readOnly") {
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
}
}
const { isolation, readOnly } = options as Record<string, unknown>;
const level = isolation === undefined ? "READ COMMITTED" : isolationStatements.get(isolation);
if (level === undefined || (readOnly !== undefined && typeof readOnly !== "boolean")) {
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
}
return `BEGIN ISOLATION LEVEL ${level} ${readOnly === true ? "READ ONLY" : "READ WRITE"}`;
}
function buildResult(
raw: RawResult,
rowMode: "array" | "object",
): ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow> {
if (typeof raw.command !== "string" || raw.command === "") {
// PostgreSQL answered with EmptyQueryResponse: the text held no statement.
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
const fields: readonly ModuleDatabaseField[] = Object.freeze(
raw.fields.map((field) => Object.freeze({ name: field.name, typeOid: field.dataTypeID })),
);
const decoded = raw.rows.map((row) => decodeModuleDatabaseRow(fields, row));
let rows: readonly (ModuleDatabaseArrayRow | ModuleDatabaseObjectRow)[];
if (rowMode === "array") {
rows = decoded;
} else {
if (new Set(fields.map((field) => field.name)).size !== fields.length) {
// Object rows cannot hold two columns with one name; refusing beats dropping one.
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
// `Object.fromEntries` defines properties, so a column named `__proto__` stays a column.
rows = decoded.map((row) =>
Object.fromEntries(fields.map((field, index) => [field.name, row[index] ?? null])),
);
}
return Object.freeze({
command: raw.command,
fields,
rowCount: raw.rowCount ?? null,
rows: Object.freeze(rows),
});
}
/** A borrowed client's `error` events are already reported through the statement they interrupt.
* The pool only listens to idle clients, so without this a connection the server terminates while
* borrowed would surface as an unhandled `error` event. */
function ignoreClientError(): void {}
function destroy(client: AbiClient): void {
client.removeListener("error", ignoreClientError);
client.release(true);
}
async function acquire(pool: Pool, failure: ModuleDatabaseErrorCode): Promise<AbiClient> {
let client: AbiClient;
try {
client = (await pool.connect()) as AbiClient;
} catch (error) {
throw databaseError(failure, error);
}
client.on("error", ignoreClientError);
if (typeof client.getTransactionStatus !== "function") {
destroy(client);
throw new ModuleDatabaseError(failure);
}
try {
await client.query(pinSessionStatement);
} catch (error) {
destroy(client);
throw databaseError(failure, error);
}
return client;
}
/** Returns a connection to the pool only when it is idle and resets cleanly; destroys it
* otherwise. Never throws. */
async function relinquish(client: AbiClient, healthy: boolean): Promise<void> {
if (!healthy || client.getTransactionStatus() !== "I") {
destroy(client);
return;
}
try {
await client.query(resetSessionStatement);
} catch {
destroy(client);
return;
}
client.removeListener("error", ignoreClientError);
client.release();
}
/** One open scope: a transaction or a savepoint inside it. */
interface Scope {
activeChild: Scope | undefined;
readonly children: Set<Scope>;
closed: boolean;
/** Operations started through this scope's executor that have not settled yet. */
pending: number;
}
interface TransactionState {
readonly client: AbiClient;
healthy: boolean;
/** Set when the transaction can no longer be committed: transaction control sent through
* `query`, or a savepoint that could not be undone. */
failure: ModuleDatabaseError | undefined;
finished: boolean;
/** Settled-never-rejecting shadows of every statement sent on the connection. */
readonly inflight: Set<Promise<void>>;
savepoints: number;
}
function newScope(parent?: Scope): Scope {
const scope: Scope = { activeChild: undefined, children: new Set(), closed: false, pending: 0 };
parent?.children.add(scope);
return scope;
}
function closeScope(scope: Scope): void {
scope.closed = true;
for (const child of scope.children) {
closeScope(child);
}
}
function assertActive(scope: Scope): void {
if (scope.closed) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
if (scope.activeChild !== undefined) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_INACTIVE");
}
}
/** Once a transaction can no longer be committed, its scopes send nothing more. After a
* transaction-control violation in particular, a later statement would otherwise run outside any
* transaction, as its own autocommitted statement. */
function assertUsable(state: TransactionState): void {
if (state.failure !== undefined) {
throw new ModuleDatabaseError(state.failure.code);
}
}
function track<T>(state: TransactionState, promise: Promise<T>): Promise<T> {
const shadow = promise.then(
() => undefined,
() => undefined,
);
state.inflight.add(shadow);
void shadow.then(() => state.inflight.delete(shadow));
return promise;
}
/** Waits until every statement already sent has settled, including any sent while waiting. */
async function drain(state: TransactionState): Promise<void> {
while (state.inflight.size > 0) {
await Promise.all([...state.inflight]);
}
}
/** Runs one of Core's own transaction-control statements. Refuses, without sending anything, once
* the transaction has finished. */
async function control(state: TransactionState, statement: string): Promise<void> {
if (state.finished) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
try {
await track(state, state.client.query(statement));
} catch (error) {
if (!connectionSurvives(error)) {
state.healthy = false;
}
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
}
}
async function rollback(state: TransactionState): Promise<void> {
try {
await state.client.query("ROLLBACK");
} catch {
state.healthy = false;
}
}
type Outcome<T> = { readonly value: T } | { readonly error: unknown };
async function settle<T>(run: () => Promise<T> | T): Promise<Outcome<T>> {
try {
return { value: await run() };
} catch (error) {
return { error };
}
}
const moduleByExecutor = new WeakMap<object, ModuleId>();
/** Which module an ABI executor, or a transaction or savepoint executor derived from it, belongs
* to. For Core's own logging, diagnostics, and attribution; never exposed to modules. */
export function moduleDatabaseExecutorModuleId(executor: object): ModuleId | undefined {
return moduleByExecutor.get(executor);
}
export interface ModuleDatabaseExecutorOptions {
/** The module this executor serves. Carried for attribution only: the ABI enforces no table
* ownership at run time (ADR 0050). */
readonly moduleId: ModuleId;
}
/**
* Creates one module's ABI v1 executor over the API's existing pool. Synchronous and free of I/O:
* nothing touches the database until the module calls it. The returned object, and every
* transaction and savepoint executor it hands out, is frozen and holds exactly the ABI's members.
*/
export function createModuleDatabaseExecutor(
pool: Pool,
options: ModuleDatabaseExecutorOptions,
): ModuleDatabaseExecutor {
const parsedModuleId = moduleIdSchema.safeParse(options.moduleId);
if (!parsedModuleId.success) {
throw new TypeError("A module database executor needs a valid module ID.");
}
const moduleId = parsedModuleId.data;
async function query(
request: unknown,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
const prepared = prepareQuery(request);
const client = await acquire(pool, "MODULE_DATABASE_QUERY_FAILED");
let healthy = true;
try {
let raw: RawResult;
try {
raw = (await client.query(prepared.config)) as unknown as RawResult;
} catch (error) {
healthy = connectionSurvives(error);
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
}
if (
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
client.getTransactionStatus() !== "I"
) {
// A transaction-control violation. PostgreSQL has already run it; the connection is
// destroyed below rather than returned in a state Core did not choose.
healthy = false;
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
return buildResult(raw, prepared.rowMode);
} finally {
await relinquish(client, healthy);
}
}
function scopeExecutor(state: TransactionState, scope: Scope): ModuleDatabaseTransaction {
async function scopeQuery(
request: unknown,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
assertActive(scope);
assertUsable(state);
const prepared = prepareQuery(request);
scope.pending += 1;
try {
let raw: RawResult;
try {
raw = (await track(state, state.client.query(prepared.config))) as unknown as RawResult;
} catch (error) {
if (!connectionSurvives(error)) {
state.healthy = false;
}
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
}
if (
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
state.client.getTransactionStatus() === "I"
) {
// A transaction-control violation. PostgreSQL has already run it, and Core cannot undo
// that; it can only never commit this transaction itself, and destroy the connection.
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
state.healthy = false;
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
return buildResult(raw, prepared.rowMode);
} finally {
scope.pending -= 1;
}
}
async function savepoint<T>(
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
): Promise<T> {
assertActive(scope);
assertUsable(state);
if (typeof callback !== "function") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
scope.pending += 1;
const child = newScope(scope);
scope.activeChild = child;
state.savepoints += 1;
// Core's own identifier: a fixed prefix and a counter, never anything a module supplied.
const name = `ekklesia_savepoint_${state.savepoints}`;
try {
await control(state, `SAVEPOINT ${name}`);
if (child.closed) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
const outcome = await settle(() => callback(scopeExecutor(state, child)));
const outstanding = child.pending > 0;
closeScope(child);
await drain(state);
if (scope.closed) {
// The enclosing transaction finished first; it rolls back, and nothing more is sent.
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
if ("error" in outcome) {
// A rejected callback's own error stays the error, even if sibling work it started was
// still settling: that work has been drained, and is undone here with everything else.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
await control(state, `RELEASE SAVEPOINT ${name}`);
} catch (error) {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
throw error;
}
throw outcome.error;
}
if (outstanding) {
// A callback that resolved with work outstanding forgot an await. Roll back to the
// savepoint and never release it.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
} catch (error) {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
throw error;
}
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
try {
await control(state, `RELEASE SAVEPOINT ${name}`);
} catch (releaseError) {
// Typically a failed statement left the transaction aborted. Undo the savepoint so the
// transaction stays usable, and report that this savepoint did not complete.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
await control(state, `RELEASE SAVEPOINT ${name}`);
} catch {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
}
throw releaseError;
}
return (outcome as { readonly value: T }).value;
} finally {
closeScope(child);
if (scope.activeChild === child) {
scope.activeChild = undefined;
}
scope.pending -= 1;
}
}
const executor = Object.freeze({
abiVersion: MODULE_DATABASE_ABI_VERSION,
query: scopeQuery as ModuleDatabaseQueryable["query"],
savepoint,
});
moduleByExecutor.set(executor, moduleId);
return executor;
}
async function transaction<T>(
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
options?: unknown,
): Promise<T> {
if (typeof callback !== "function") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
const begin = beginStatement(options);
const client = await acquire(pool, "MODULE_DATABASE_TRANSACTION_FAILED");
const state: TransactionState = {
client,
failure: undefined,
finished: false,
healthy: true,
inflight: new Set(),
savepoints: 0,
};
try {
try {
await client.query(begin);
} catch (error) {
state.healthy = connectionSurvives(error);
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
}
const scope = newScope();
const outcome = await settle(() => callback(scopeExecutor(state, scope)));
const outstanding = scope.pending > 0;
closeScope(scope);
await drain(state);
state.finished = true;
if ("error" in outcome) {
// The callback's own error stays primary, even when sibling work it started (say, the
// other half of a `Promise.all`) was still settling. That work has been drained, and rolls
// back with the rest; outstanding work never leads to a commit.
await rollback(state);
throw outcome.error;
}
if (outstanding) {
// The callback resolved with work it never awaited: a forgotten `await`.
await rollback(state);
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
if (state.failure !== undefined) {
await rollback(state);
throw state.failure;
}
let committed: { readonly command: string | null };
try {
committed = await client.query("COMMIT");
} catch (error) {
state.healthy = connectionSurvives(error);
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
}
if (committed.command !== "COMMIT") {
// PostgreSQL answers COMMIT with ROLLBACK when a failed statement had aborted the
// transaction. Nothing was committed, and PostgreSQL supplied no SQLSTATE to report.
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
}
return outcome.value;
} finally {
state.finished = true;
await relinquish(client, state.healthy);
}
}
const executor = Object.freeze({
abiVersion: MODULE_DATABASE_ABI_VERSION,
query: query as ModuleDatabaseQueryable["query"],
transaction: transaction as ModuleDatabaseExecutor["transaction"],
});
moduleByExecutor.set(executor, moduleId);
return executor;
}
+49 -2
View File
@@ -1,7 +1,54 @@
import type { ModuleDatabase } from "@ekklesia/contracts";
import { drizzle } from "drizzle-orm/node-postgres";
import type { ModuleDatabaseAbiRuntimeServices, ModuleRuntimeServices } from "@ekklesia/contracts";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
/**
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
* platform-owned Drizzle instance exposes, restricted to exactly the operations a module needs to
* read and write its own tables. Derived with `Pick` from the real `drizzle-orm/node-postgres`
* database type rather than hand-declared, so every method keeps Drizzle's own table-aware
* argument and result inference exactly as-is — a module calling `database.select().from(table)`
* or `database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
* would, with no `any`/`unknown` erasure anywhere in the chain.
*
* Deliberately excludes everything `NodePgDatabase` or the `drizzle(...)` factory otherwise
* exposes: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
* `query` (a relational-query builder tied to one schema, which would have to span every
* installed module's tables to exist here at all), `$cache`, `$count`, `$with`, `with`, and
* `refreshMaterializedView`. There is no connection string, pool, session, or dialect object
* reachable through this type. Carries no schema generic — a module brings its own Drizzle table
* objects to each call, so there is no global relational schema spanning every installed module for
* this type to leak.
*
* Core's own, private type (ADR 0053): it moved here from `@ekklesia/contracts` so no published
* package references Drizzle. New modules use the Module Database ABI v1 instead (ADR 0050), and
* the Module SDK never exposes this facade's type.
*
* This is an integration boundary, not a security sandbox: modules are trusted, compiled backend
* plugins that already run arbitrary code in this process, exactly like every other lifecycle hook
* and HTTP handler they define. `ModuleDatabase` exists to keep a module's code Drizzle-typed and
* decoupled from how the platform physically connects to PostgreSQL — not to defend against a
* malicious module, which this facade cannot do and does not attempt to do.
*/
export type ModuleDatabase = Pick<
NodePgDatabase,
| "select"
| "selectDistinct"
| "selectDistinctOn"
| "insert"
| "update"
| "delete"
| "execute"
| "transaction"
>;
/** The services a legacy module's hooks and handlers receive: the legacy facade. */
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
/** The services Core hands any started module: the legacy facade or the ABI v1 executor. */
export type AnyModuleRuntimeServices =
| LegacyModuleRuntimeServices
| ModuleDatabaseAbiRuntimeServices;
/**
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
+14
View File
@@ -9,6 +9,7 @@ import {
safeParseModuleManifest,
semanticVersionSchema,
} from "@ekklesia/contracts";
import { isReservedModuleId } from "./reserved-ids.js";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
@@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode =
| "MODULE_ENTRYPOINT_INVALID"
| "MODULE_ENTRYPOINT_MISSING"
| "MODULE_ID_DUPLICATE"
| "MODULE_ID_RESERVED"
| "MODULE_MANIFEST_INVALID"
| "MODULE_MANIFEST_MISSING"
| "MODULE_PACKAGE_INVALID"
@@ -614,6 +616,18 @@ async function scanModuleDirectory(
});
}
if (isReservedModuleId(manifest.id)) {
// A module's Dashboard page is `/<module-id>` (ADR 0057), in the same URL space as the
// platform's own top-level namespaces.
issues.push({
code: "MODULE_ID_RESERVED",
message: `Module ID ${manifest.id} is reserved: the platform uses /${manifest.id} for its own pages and routes.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "id",
});
}
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!artifacts) {
return undefined;
+4
View File
@@ -2,14 +2,18 @@ export * from "./configuration.js";
export * from "./configuration-schema.js";
export * from "./configuration-store.js";
export * from "./database.js";
export * from "./database-abi.js";
export * from "./discovery.js";
export * from "./lifecycle.js";
export * from "./lifecycle-schema.js";
export * from "./lifecycle-store.js";
export * from "./migration-discovery.js";
export * from "./modules-directory.js";
export * from "./module-package-host.js";
export * from "./module-package-verification.js";
export * from "./permissions.js";
export * from "./realtime.js";
export * from "./reserved-ids.js";
export * from "./runtime.js";
export * from "./schema-discovery.js";
export * from "./ui-publication.js";
@@ -1,19 +1,15 @@
/**
* The legacy module schema definition (ADR 0007): a v8 module that declares no `database` default-
* exports `{ moduleId, tables }` with its Drizzle table objects themselves. Core alone validates it,
* for the legacy facade's deprecation window (ADR 0050). It lives here, beside Core's own Drizzle
* dependency, so that no package a module installs needs Drizzle at run time (ADR 0053).
*
* Erasable TypeScript with no relative imports: the verifier's probe imports this source file
* directly in tests (`module-package-probe.ts`).
*/
import { is } from "drizzle-orm";
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
import { z } from "zod";
import { type ModuleId, moduleIdSchema } from "./identifiers.js";
export const POSTGRES_IDENTIFIER_MAX_LENGTH = 63;
export const moduleTableLocalNameSchema = z
.string()
.min(1)
.regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u, "Must be a lowercase snake-case table name.");
export const physicalModuleTableNameSchema = moduleTableLocalNameSchema.max(
POSTGRES_IDENTIFIER_MAX_LENGTH,
`Must not exceed ${POSTGRES_IDENTIFIER_MAX_LENGTH} characters.`,
);
import { type ModuleId, moduleIdSchema } from "@ekklesia/contracts";
export interface ModuleSchemaDefinition<
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
@@ -35,23 +31,6 @@ function hasOnlyStringKeys(value: object, allowedKeys?: ReadonlySet<string>): bo
);
}
export function moduleTableNamespace(moduleId: ModuleId): string {
return moduleIdSchema.parse(moduleId).replaceAll("-", "_");
}
export function moduleTablePrefix(moduleId: ModuleId): string {
return `${moduleTableNamespace(moduleId)}_`;
}
export function moduleTableName(moduleId: ModuleId, localName: string): string {
const parsedLocalName = moduleTableLocalNameSchema.parse(localName);
const tableName = `${moduleTablePrefix(moduleId)}${parsedLocalName}`;
if (!physicalModuleTableNameSchema.safeParse(tableName).success) {
throw new RangeError("The namespaced PostgreSQL table name is invalid or too long.");
}
return tableName;
}
export function isModuleSchemaDefinition(value: unknown): value is ModuleSchemaDefinition {
try {
if (
+203
View File
@@ -0,0 +1,203 @@
import { gunzipSync } from "node:zlib";
/**
* Reads an Ekklesia Module Package v1 archive (ADR 0049) entirely in memory and enforces the
* archive-safety rules before anything is written anywhere.
*
* Deliberately a strict reader rather than a general tar implementation: v1 packages hold only
* POSIX ustar directories and regular files, so every other entry type is refused instead of
* interpreted. Nothing here touches the filesystem; extraction happens only after the caller has
* verified the whole package.
*/
export type ModulePackageArchiveErrorCode =
| "MODULE_PACKAGE_LAYOUT_INVALID"
| "MODULE_PACKAGE_LINK"
| "MODULE_PACKAGE_SPECIAL_ENTRY"
| "MODULE_PACKAGE_TOO_LARGE"
| "MODULE_PACKAGE_UNREADABLE"
| "MODULE_PACKAGE_UNSAFE_MODE"
| "MODULE_PACKAGE_UNSAFE_PATH";
export class ModulePackageArchiveError extends Error {
readonly code: ModulePackageArchiveErrorCode;
constructor(code: ModulePackageArchiveErrorCode) {
super(code);
this.name = "ModulePackageArchiveError";
this.code = code;
}
}
/** Generous for a module, small enough that a hostile archive cannot exhaust the host. */
export const MODULE_PACKAGE_MAX_COMPRESSED_BYTES = 256 * 1024 * 1024;
export const MODULE_PACKAGE_MAX_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024;
export const MODULE_PACKAGE_MAX_ENTRIES = 100_000;
export interface ModulePackageArchiveEntry {
/** Relative to the top-level module directory; empty for that directory itself. */
readonly path: string;
readonly type: "directory" | "file";
readonly data: Buffer;
}
export interface ModulePackageArchive {
/** The single top-level directory's name. Validated as a module id by the caller. */
readonly topLevel: string;
/** Every entry except the top-level directory itself, in archive order. */
readonly entries: readonly ModulePackageArchiveEntry[];
}
const BLOCK = 512;
function field(block: Buffer, offset: number, length: number): string {
const raw = block.subarray(offset, offset + length);
const end = raw.indexOf(0);
return raw.subarray(0, end === -1 ? length : end).toString("latin1");
}
function octalField(block: Buffer, offset: number, length: number): number {
const text = field(block, offset, length).trim();
if (!/^[0-7]+$/u.test(text)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
const value = Number.parseInt(text, 8);
if (!Number.isSafeInteger(value)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
return value;
}
function headerChecksumValid(block: Buffer): boolean {
const declared = octalField(block, 148, 8);
let sum = 0;
for (let index = 0; index < BLOCK; index += 1) {
sum += index >= 148 && index < 156 ? 0x20 : (block[index] ?? 0);
}
return sum === declared;
}
/** Printable ASCII, no backslash, relative, and no empty, `.`, or `..` segment. */
function safeSegments(value: string): readonly string[] {
if (
value.length === 0 ||
!/^[\x20-\x7e]+$/u.test(value) ||
value.includes("\\") ||
value.startsWith("/")
) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_PATH");
}
const segments = value.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_PATH");
}
return segments;
}
export function readModulePackageArchive(compressed: Buffer): ModulePackageArchive {
if (compressed.length > MODULE_PACKAGE_MAX_COMPRESSED_BYTES) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_TOO_LARGE");
}
let raw: Buffer;
try {
raw = gunzipSync(compressed, { maxOutputLength: MODULE_PACKAGE_MAX_UNCOMPRESSED_BYTES });
} catch (error) {
throw new ModulePackageArchiveError(
error instanceof RangeError ? "MODULE_PACKAGE_TOO_LARGE" : "MODULE_PACKAGE_UNREADABLE",
);
}
const entries: ModulePackageArchiveEntry[] = [];
const kinds = new Map<string, "directory" | "file">();
/** Every path that is an ancestor of some entry, so a file can never claim one. */
const ancestors = new Set<string>();
let topLevel: string | undefined;
let offset = 0;
let ended = false;
while (offset + BLOCK <= raw.length) {
const block = raw.subarray(offset, offset + BLOCK);
if (block.every((byte) => byte === 0)) {
ended = true;
break;
}
if (field(block, 257, 6) !== "ustar" || field(block, 263, 2) !== "00") {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
if (!headerChecksumValid(block)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
if (entries.length + 1 > MODULE_PACKAGE_MAX_ENTRIES) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_TOO_LARGE");
}
const typeflag = field(block, 156, 1);
if (typeflag === "1" || typeflag === "2") {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LINK");
}
const type =
typeflag === "5" ? "directory" : typeflag === "0" || typeflag === "" ? "file" : undefined;
if (type === undefined) {
// Devices, FIFOs, contiguous files, and every extended or long-name header: v1 packages hold
// directories and regular files, so nothing else is interpreted.
throw new ModulePackageArchiveError("MODULE_PACKAGE_SPECIAL_ENTRY");
}
const mode = octalField(block, 100, 8);
if ((mode & 0o7000) !== 0) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_MODE");
}
const name = field(block, 0, 100);
const prefix = field(block, 345, 155);
let fullPath = prefix === "" ? name : `${prefix}/${name}`;
if (type === "directory" && fullPath.endsWith("/")) {
fullPath = fullPath.slice(0, -1);
}
const segments = safeSegments(fullPath);
const top = segments[0] ?? "";
if (topLevel === undefined) {
topLevel = top;
} else if (top !== topLevel) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
const size = type === "file" ? octalField(block, 124, 12) : 0;
const dataStart = offset + BLOCK;
const dataEnd = dataStart + size;
if (dataEnd > raw.length) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
const relative = segments.slice(1).join("/");
if (relative === "") {
if (type !== "directory") {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
} else {
if (kinds.has(relative)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
// No entry may sit below a file, and no file may claim a path something already sits below.
// Linear in the total number of path segments, whatever order the entries arrive in.
if (type === "file" && ancestors.has(relative)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
for (let index = 1; index < segments.length - 1; index += 1) {
const ancestor = segments.slice(1, index + 1).join("/");
if (kinds.get(ancestor) === "file") {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
ancestors.add(ancestor);
}
kinds.set(relative, type);
entries.push({ data: Buffer.from(raw.subarray(dataStart, dataEnd)), path: relative, type });
}
offset = dataStart + Math.ceil(size / BLOCK) * BLOCK;
}
if (!ended || topLevel === undefined) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
return Object.freeze({ entries: Object.freeze(entries), topLevel });
}
+59
View File
@@ -0,0 +1,59 @@
/**
* The facts about this host that decide whether a native Module Package v1 can run on it
* (ADR 0049): operating system, CPU, C library family and version, and Node-API version.
*
* Everything comes from Node.js itself (`process.platform`, `process.arch`, `process.versions`, and
* the diagnostic report's `glibcVersionRuntime`), never from running a system command. A fact Node
* cannot report is left undefined, and every check that needs it fails closed.
*/
export interface ModulePackageHost {
readonly platform: string;
readonly arch: string;
/** The C library on Linux: glibc with its runtime version, or undefined when it cannot be proven
* to be glibc (musl reports none). */
readonly libc?: { readonly family: "glibc"; readonly version: string };
/** `process.versions.napi`: the highest Node-API version this Node.js provides. */
readonly nodeApi?: number;
/** `process.versions.modules`: the ABI an ABI-specific addon must have been built for. */
readonly nodeAbi?: string;
}
function glibcVersion(): string | undefined {
try {
const header = (process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } })
?.header;
const version = header?.glibcVersionRuntime;
return typeof version === "string" && /^\d+\.\d+(?:\.\d+)?$/u.test(version)
? version
: undefined;
} catch {
return undefined;
}
}
export function currentModulePackageHost(): ModulePackageHost {
const libc = process.platform === "linux" ? glibcVersion() : undefined;
const nodeApi = Number(process.versions.napi);
return Object.freeze({
arch: process.arch,
...(libc === undefined
? {}
: { libc: Object.freeze({ family: "glibc" as const, version: libc }) }),
...(Number.isInteger(nodeApi) && nodeApi > 0 ? { nodeApi } : {}),
...(typeof process.versions.modules === "string" ? { nodeAbi: process.versions.modules } : {}),
platform: process.platform,
});
}
/** The native targets this platform can verify, with what each requires of a host. */
export const MODULE_PACKAGE_NATIVE_TARGETS: ReadonlyMap<
string,
{
readonly platform: string;
readonly arch: string;
readonly libc: "glibc";
readonly elfMachine: number;
}
> = new Map([
["linux-x64-glibc", { arch: "x64", elfMachine: 62, libc: "glibc", platform: "linux" }],
]);
+101
View File
@@ -0,0 +1,101 @@
// The isolated, short-lived validation process of Module Package v1 verification (ADR 0049).
//
// The verifier forks this file with an empty environment and one JSON argument naming a candidate
// module's backend entrypoints. It imports each one, so a package whose code cannot load, or whose
// bundle still reaches for a dependency it does not contain, is refused before it is ever installed.
// Whatever the module's top-level code does happens here, in a process with no database URL, no
// secret, and no platform state, and that is discarded as soon as it reports.
//
// The result travels over the IPC channel, never stdout, so nothing a module prints can forge it.
// It carries fixed role and reason names only: never an error message, a path, or module output.
//
// Written as erasable TypeScript, so Node can run the source file directly in tests as well as the
// compiled file in the image. Its one relative import names the source file's own extension, which
// the build rewrites to `.js` (`rewriteRelativeImportExtensions`); that file follows the same rules.
import { pathToFileURL } from "node:url";
import {
parseModuleRuntimeDefinition,
parseModuleSchemaMetadataDefinition,
} from "@ekklesia/contracts";
import { parseModuleSchemaDefinition } from "./legacy-schema-definition.ts";
interface ProbeRequest {
readonly databaseAbi?: 1;
readonly moduleId: string;
readonly version: string;
readonly entrypoints: Readonly<Record<string, string>>;
}
interface ProbeFailure {
readonly role: string;
readonly reason: "contract" | "export" | "identity" | "load";
}
function defaultExport(namespace: unknown): unknown {
return typeof namespace === "object" && namespace !== null && "default" in namespace
? (namespace as { default: unknown }).default
: undefined;
}
async function probe(request: ProbeRequest): Promise<readonly ProbeFailure[]> {
const failures: ProbeFailure[] = [];
for (const role of Object.keys(request.entrypoints).sort()) {
const entrypoint = request.entrypoints[role];
if (entrypoint === undefined) {
continue;
}
let namespace: unknown;
try {
namespace = await import(pathToFileURL(entrypoint).href);
} catch {
failures.push({ reason: "load", role });
continue;
}
try {
if (role === "server") {
const definition = parseModuleRuntimeDefinition(defaultExport(namespace));
if (definition.id !== request.moduleId || definition.version !== request.version) {
failures.push({ reason: "identity", role });
}
if ((definition.database?.abi === 1) !== (request.databaseAbi === 1)) {
failures.push({ reason: "contract", role });
}
} else if (role === "schema") {
const definition =
request.databaseAbi === 1
? parseModuleSchemaMetadataDefinition(defaultExport(namespace))
: parseModuleSchemaDefinition(defaultExport(namespace));
if (definition.moduleId !== request.moduleId) {
failures.push({ reason: "identity", role });
}
}
} catch {
failures.push({ reason: "export", role });
}
}
return failures;
}
function report(message: unknown): void {
if (typeof process.send !== "function") {
process.exitCode = 2;
return;
}
process.send(message, () => {
// A module may leave timers or handles behind; the probe's work is done either way.
process.exit(0);
});
}
let request: ProbeRequest | undefined;
try {
request = JSON.parse(process.argv[2] ?? "") as ProbeRequest;
} catch {
report({ failures: [{ reason: "load", role: "request" }] });
}
if (request !== undefined) {
probe(request).then(
(failures) => report({ failures }),
() => report({ failures: [{ reason: "load", role: "probe" }] }),
);
}
@@ -0,0 +1,650 @@
import { fork } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
isVersionCompatible,
type ModuleManifest,
safeParseModuleManifest,
} from "@ekklesia/contracts";
import {
MODULE_PACKAGE_ANY_TARGET,
MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_METADATA_FILENAME,
type ModulePackageMetadata,
safeParseModulePackageMetadata,
safeParseModulePackageRuntimePackage,
} from "@ekklesia/contracts/module-package";
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
import {
currentModulePackageHost,
MODULE_PACKAGE_NATIVE_TARGETS,
type ModulePackageHost,
} from "./module-package-host.js";
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
import {
type ModulePackageArchive,
ModulePackageArchiveError,
type ModulePackageArchiveErrorCode,
readModulePackageArchive,
} from "./module-package-archive.js";
import { discoverModuleSchemas, ModuleSchemaDiscoveryError } from "./schema-discovery.js";
/**
* Verifies an Ekklesia Module Package v1 archive (ADR 0049) against the platform it runs on, without
* ever installing it.
*
* Order matters: everything that can be decided from bytes is decided before anything is written,
* and nothing is executed until the package has proven itself well-formed. The archive is read and
* checked for safety in memory; its metadata, every payload digest, and every identity statement
* are checked next; only then is it written to a fresh temporary directory, validated by the same
* discovery the platform uses, and its backend loaded once in a separate, short-lived process with
* an empty environment. A caller that asks for extraction gets the verified tree written to a new
* directory only after all of that succeeded.
*
* Errors carry a stable code and a fixed message. Paths, archive entry names, file contents, module
* output, and exception messages never appear in them: an archive is untrusted input, and none of
* it is ever echoed.
*/
export type ModulePackageVerificationErrorCode =
| ModulePackageArchiveErrorCode
| "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"
| "MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED"
| "MODULE_PACKAGE_DIGEST_MISMATCH"
| "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED"
| "MODULE_PACKAGE_EXTRACTION_FAILED"
| "MODULE_PACKAGE_FILE_MISMATCH"
| "MODULE_PACKAGE_FILE_MISSING"
| "MODULE_PACKAGE_FILE_UNLISTED"
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
| "MODULE_PACKAGE_LIBC_INCOMPATIBLE"
| "MODULE_PACKAGE_MANIFEST_INVALID"
| "MODULE_PACKAGE_METADATA_INVALID"
| "MODULE_PACKAGE_METADATA_MISSING"
| "MODULE_PACKAGE_MODULE_INVALID"
| "MODULE_PACKAGE_NATIVE_FILE"
| "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE"
| "MODULE_PACKAGE_NODE_API_INCOMPATIBLE"
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
"The package's bundled database runtime does not match this platform's (a transitional constraint).",
MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED:
"A Module Database ABI package must not carry a transitional database runtime.",
MODULE_PACKAGE_DIGEST_MISMATCH: "The archive does not match the SHA-256 it was given.",
MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED:
"A backend entrypoint could not be loaded from the package alone.",
MODULE_PACKAGE_EXTRACTION_FAILED:
"The verified package could not be extracted to a new directory.",
MODULE_PACKAGE_FILE_MISMATCH: "A payload file does not match its listed size or SHA-256.",
MODULE_PACKAGE_FILE_MISSING: "A file listed in the package metadata is missing from the archive.",
MODULE_PACKAGE_FILE_UNLISTED: "The archive contains a file the package metadata does not list.",
MODULE_PACKAGE_FORMAT_UNSUPPORTED: "The package format is not one this platform implements.",
MODULE_PACKAGE_IDENTITY_MISMATCH:
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
MODULE_PACKAGE_LAYOUT_INVALID:
"The archive must contain exactly one top-level module directory, with each path once.",
MODULE_PACKAGE_LIBC_INCOMPATIBLE:
"The package needs a newer C library than this host provides, or one it cannot establish.",
MODULE_PACKAGE_LINK: "The archive contains a link; module packages may contain none.",
MODULE_PACKAGE_MANIFEST_INVALID: "The package's module manifest is missing or invalid.",
MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.",
MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.",
MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.",
MODULE_PACKAGE_NATIVE_FILE:
"An any-target package contains a native file or a node_modules directory.",
MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE:
"A native file in the package is not built for the package's target.",
MODULE_PACKAGE_NODE_API_INCOMPATIBLE:
"The package's addons need a Node-API version or Node.js ABI this host does not provide.",
MODULE_PACKAGE_RUNTIME_INCOMPATIBLE:
"The package needs a Node.js version this host does not run.",
MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID:
"The package's runtime package.json is not the generated runtime shape.",
MODULE_PACKAGE_SDK_INCOMPATIBLE:
"The Module SDK bundled into the package is outside the manifest's own moduleSdk range.",
MODULE_PACKAGE_SPECIAL_ENTRY:
"The archive contains an entry that is neither a directory nor a regular file.",
MODULE_PACKAGE_TARGET_INCOMPATIBLE: "The package's target is not one this platform can run.",
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
"The package metadata contains transitional facts this platform does not understand.",
MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE:
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
});
export class ModulePackageVerificationError extends Error {
readonly code: ModulePackageVerificationErrorCode;
/** Stable discovery issue codes, when module validation failed; never their messages. */
readonly issueCodes: readonly string[];
constructor(code: ModulePackageVerificationErrorCode, issueCodes: readonly string[] = []) {
super(messages[code]);
this.name = "ModulePackageVerificationError";
this.code = code;
this.issueCodes = Object.freeze([...new Set(issueCodes)].sort());
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/** What this platform is, for the checks that compare a package against it. */
export interface ModulePackagePlatform {
readonly platformVersion: string;
readonly moduleSdkVersion: string;
/** The transitional database runtime facts this platform requires a package to agree with. */
readonly databaseRuntime: Readonly<Record<string, string>>;
/** Defaults to the running process's Node.js version. */
readonly nodeVersion?: string;
/** Defaults to this process's own host (`currentModulePackageHost`). */
readonly host?: ModulePackageHost;
}
export interface VerifyModulePackageOptions {
readonly archivePath: string;
/** When given, the archive's SHA-256 must equal it (lowercase hexadecimal). */
readonly expectedSha256?: string;
readonly platform: ModulePackagePlatform;
/** A directory that must not exist yet; the verified module directory is written there. */
readonly extractTo?: string;
/**
* Existing writable directory where backend candidates may execute. Defaults to `os.tmpdir()`.
* A hardened caller whose system temporary directory is `noexec` must provide a dedicated
* executable scratch mount; each verification creates and removes its own child directory.
*/
readonly executionScratchDirectory?: string;
readonly loadTimeoutMs?: number;
}
export interface ModulePackageVerificationResult {
readonly archiveSha256: string;
/** Payload files, the metadata file not included. */
readonly fileCount: number;
readonly moduleId: string;
readonly target: string;
readonly version: string;
}
function fail(code: ModulePackageVerificationErrorCode, issueCodes?: readonly string[]): never {
throw new ModulePackageVerificationError(code, issueCodes);
}
function sha256(data: Uint8Array): string {
return createHash("sha256").update(data).digest("hex");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseJson(data: Buffer | undefined): unknown {
if (data === undefined) {
return undefined;
}
try {
return JSON.parse(data.toString("utf8")) as unknown;
} catch {
return undefined;
}
}
/** The transitional facts this verifier understands, and nothing else (ADR 0049). */
const UNDERSTOOD_TRANSITIONAL: Readonly<Record<string, readonly string[]>> = Object.freeze({
databaseRuntime: Object.freeze(["drizzle-orm"]),
});
function checkTransitional(raw: Record<string, unknown>): void {
const transitional = raw.transitional;
if (transitional === undefined) {
return;
}
if (!isRecord(transitional)) {
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
}
for (const [key, value] of Object.entries(transitional)) {
const understood = UNDERSTOOD_TRANSITIONAL[key];
if (!understood || !isRecord(value)) {
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
}
if (Object.keys(value).some((name) => !understood.includes(name))) {
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
}
}
}
function readMetadata(files: ReadonlyMap<string, Buffer>): ModulePackageMetadata {
const raw = parseJson(files.get(MODULE_PACKAGE_METADATA_FILENAME));
if (!files.has(MODULE_PACKAGE_METADATA_FILENAME)) {
fail("MODULE_PACKAGE_METADATA_MISSING");
}
if (!isRecord(raw)) {
fail("MODULE_PACKAGE_METADATA_INVALID");
}
if (raw.packageFormat !== MODULE_PACKAGE_FORMAT) {
fail("MODULE_PACKAGE_FORMAT_UNSUPPORTED");
}
checkTransitional(raw);
const parsed = safeParseModulePackageMetadata(raw);
if (!parsed.success) {
fail("MODULE_PACKAGE_METADATA_INVALID");
}
return parsed.data;
}
function checkPayload(metadata: ModulePackageMetadata, files: ReadonlyMap<string, Buffer>): void {
const listed = new Set(metadata.files.map((file) => file.path));
for (const filePath of files.keys()) {
if (filePath !== MODULE_PACKAGE_METADATA_FILENAME && !listed.has(filePath)) {
fail("MODULE_PACKAGE_FILE_UNLISTED");
}
}
for (const file of metadata.files) {
const data = files.get(file.path);
if (data === undefined) {
fail("MODULE_PACKAGE_FILE_MISSING");
}
if (data.length !== file.size || sha256(data) !== file.sha256) {
fail("MODULE_PACKAGE_FILE_MISMATCH");
}
}
}
function checkIdentity(
archive: ModulePackageArchive,
metadata: ModulePackageMetadata,
files: ReadonlyMap<string, Buffer>,
): ModuleManifest {
const runtimePackage = safeParseModulePackageRuntimePackage(parseJson(files.get("package.json")));
if (!runtimePackage.success) {
fail("MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID");
}
const manifest = safeParseModuleManifest(parseJson(files.get(MODULE_MANIFEST_FILENAME)));
if (!manifest.success) {
fail("MODULE_PACKAGE_MANIFEST_INVALID");
}
if (
archive.topLevel !== metadata.module.id ||
manifest.data.id !== metadata.module.id ||
manifest.data.version !== metadata.module.version ||
runtimePackage.data.version !== metadata.module.version
) {
fail("MODULE_PACKAGE_IDENTITY_MISMATCH");
}
return manifest.data;
}
function checkCompatibility(
metadata: ModulePackageMetadata,
manifest: ModuleManifest,
files: ReadonlyMap<string, Buffer>,
platform: ModulePackagePlatform,
): void {
if (metadata.target === MODULE_PACKAGE_ANY_TARGET) {
// An any-target package bundles everything: no addon, no ELF object, no node_modules.
for (const [filePath, data] of files) {
if (
filePath.toLowerCase().endsWith(".node") ||
filePath.startsWith("node_modules/") ||
isElf(data)
) {
fail("MODULE_PACKAGE_NATIVE_FILE");
}
}
} else {
checkNativeTarget(metadata, files, platform.host ?? currentModulePackageHost());
}
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
}
const sdk = metadata.incorporates["@ekklesia/module-sdk"];
if (sdk !== undefined && !isVersionCompatible(sdk, manifest.compatibility.moduleSdk)) {
fail("MODULE_PACKAGE_SDK_INCOMPATIBLE");
}
if (manifest.database?.abi === 1) {
if (metadata.transitional?.databaseRuntime !== undefined) {
fail("MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED");
}
return;
}
// Transitional legacy-v8 path (ADR 0049): Core queries the module's Drizzle tables with its own
// Drizzle instance, so a recorded module runtime must match. ABI-v1 packages return above: their
// bundled ORM is a private implementation detail and is never compared with Core's.
for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) {
if (platform.databaseRuntime[runtime] !== version) {
fail("MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE");
}
}
}
function isElf(data: Uint8Array): boolean {
return (
data.length >= 4 && data[0] === 0x7f && data[1] === 0x45 && data[2] === 0x4c && data[3] === 0x46
);
}
/** `2.41` → `2.41.0`, for comparison against a semantic-version range. */
function asSemver(version: string): string {
const parts = version.split(".");
return [...parts, "0", "0"].slice(0, 3).join(".");
}
/**
* A native target (ADR 0049): accepted only for a target this platform implements, on a host that
* provably matches it. Platform, CPU, C library family and minimum version, and the Node-API version
* or ABI the addons need are all checked; a fact the host cannot establish fails closed. Every ELF
* object in the package must be built for the target's machine, and every `.node` addon must be one.
*/
function checkNativeTarget(
metadata: ModulePackageMetadata,
files: ReadonlyMap<string, Buffer>,
host: ModulePackageHost,
): void {
const target = MODULE_PACKAGE_NATIVE_TARGETS.get(metadata.target);
if (
target === undefined ||
host.platform !== target.platform ||
host.arch !== target.arch ||
host.libc?.family !== target.libc
) {
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
}
const required = metadata.runtime.libcVersion;
if (
required === undefined ||
host.libc === undefined ||
!isVersionCompatible(asSemver(host.libc.version), required)
) {
fail("MODULE_PACKAGE_LIBC_INCOMPATIBLE");
}
const { nodeAbi, nodeApi } = metadata.runtime;
if (
(nodeApi === undefined && nodeAbi === undefined) ||
(nodeApi !== undefined && (host.nodeApi === undefined || host.nodeApi < nodeApi)) ||
(nodeAbi !== undefined && host.nodeAbi !== nodeAbi)
) {
fail("MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
}
for (const [filePath, data] of files) {
const addon = filePath.toLowerCase().endsWith(".node");
if (!isElf(data)) {
if (addon) {
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
}
continue;
}
// ELF64, little-endian, for the target's machine (e_machine at offset 18).
if (
data.length < 20 ||
data[4] !== 2 ||
data[5] !== 1 ||
data.readUInt16LE(18) !== target.elfMachine
) {
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
}
}
}
/** Writes the archive's directories (`0755`) and files (`0644`) under `root`, which must not exist.
* Safe by construction: every path was validated as relative and non-escaping, and only directories
* and regular files exist. Ownership is the writing process's, never the archive's. */
async function writeTree(root: string, archive: ModulePackageArchive): Promise<void> {
await mkdir(root, { mode: 0o755 });
await chmod(root, 0o755);
for (const entry of archive.entries) {
const target = path.join(root, ...entry.path.split("/"));
if (entry.type === "directory") {
await mkdir(target, { mode: 0o755, recursive: true });
await chmod(target, 0o755);
} else {
await mkdir(path.dirname(target), { mode: 0o755, recursive: true });
await writeFile(target, entry.data, { flag: "wx", mode: 0o644 });
await chmod(target, 0o644);
}
}
}
function probeEntrypointPath(): string {
// The probe sits beside this file: compiled `.js` in an installation, source `.ts` under a test
// runner, which Node runs directly. Either way it is a separate process.
const self = fileURLToPath(import.meta.url);
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
}
function decodedMountPath(value: string): string {
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
}
/** Linux exposes the effective options of every mount, including a container's tmpfs mounts. A
* verifier cannot infer `noexec` from file permissions: native addons are mapped executable rather
* than invoked as files. On systems without mountinfo, the load remains the final authority. */
async function isNoExecMount(directory: string): Promise<boolean> {
if (process.platform !== "linux") return false;
let mountinfo: string;
let canonicalDirectory: string;
try {
[mountinfo, canonicalDirectory] = await Promise.all([
readFile("/proc/self/mountinfo", "utf8"),
realpath(directory),
]);
} catch {
return false;
}
const mounts = mountinfo
.split("\n")
.map((line) => line.split(" "))
.filter((fields) => fields.length > 6 && fields.includes("-"))
.map((fields) => ({
mountPoint: decodedMountPath(fields[4] ?? ""),
options: (fields[5] ?? "").split(","),
}))
.filter(
({ mountPoint }) =>
canonicalDirectory === mountPoint ||
canonicalDirectory.startsWith(mountPoint === "/" ? "/" : `${mountPoint}/`),
)
.sort((left, right) => right.mountPoint.length - left.mountPoint.length);
return mounts[0]?.options.includes("noexec") ?? false;
}
async function createExecutionScratch(baseDirectory: string): Promise<string> {
if (!path.isAbsolute(baseDirectory)) {
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
try {
if (await isNoExecMount(baseDirectory)) {
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
return await mkdtemp(path.join(baseDirectory, "ekklesia-package-verify-"));
} catch (error) {
if (error instanceof ModulePackageVerificationError) throw error;
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
}
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
* only whether each loaded and exported a definition naming this module. */
async function loadEntrypoints(
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
entrypoints: Readonly<Record<string, string>>,
timeoutMs: number,
executionDirectory: string,
): Promise<void> {
const outcome = await new Promise<"ok" | "failed">((resolve) => {
let settled = false;
let child: ReturnType<typeof fork> | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (value: "ok" | "failed"): void => {
if (!settled) {
settled = true;
clearTimeout(timer);
child?.kill("SIGKILL");
resolve(value);
}
};
try {
child = fork(
probeEntrypointPath(),
[
JSON.stringify({
...(manifest.database?.abi === 1 ? { databaseAbi: 1 } : {}),
entrypoints,
moduleId: manifest.id,
version: manifest.version,
}),
],
{
cwd: executionDirectory,
env: {},
execArgv: [],
serialization: "json",
stdio: ["ignore", "ignore", "ignore", "ipc"],
},
);
} catch {
finish("failed");
return;
}
timer = setTimeout(() => finish("failed"), timeoutMs);
child.on("message", (message: unknown) => {
const failures = isRecord(message) ? message.failures : undefined;
finish(Array.isArray(failures) && failures.length === 0 ? "ok" : "failed");
});
child.on("error", () => finish("failed"));
child.on("exit", () => finish("failed"));
});
if (outcome !== "ok") {
fail("MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED");
}
}
function discoveryIssueCodes(error: unknown): readonly string[] {
if (
error instanceof ModuleDiscoveryError ||
error instanceof ModuleMigrationDiscoveryError ||
error instanceof ModuleSchemaDiscoveryError
) {
return error.issues.map((issue) => issue.code);
}
return [];
}
export async function verifyModulePackage(
options: VerifyModulePackageOptions,
): Promise<ModulePackageVerificationResult> {
let compressed: Buffer;
try {
compressed = await readFile(options.archivePath);
} catch {
fail("MODULE_PACKAGE_UNREADABLE");
}
const archiveSha256 = sha256(compressed);
if (options.expectedSha256 !== undefined && options.expectedSha256 !== archiveSha256) {
fail("MODULE_PACKAGE_DIGEST_MISMATCH");
}
let archive: ModulePackageArchive;
try {
archive = readModulePackageArchive(compressed);
} catch (error) {
if (error instanceof ModulePackageArchiveError) {
fail(error.code);
}
fail("MODULE_PACKAGE_UNREADABLE");
}
const files = new Map(
archive.entries
.filter((entry) => entry.type === "file")
.map((entry) => [entry.path, entry.data] as const),
);
const metadata = readMetadata(files);
checkPayload(metadata, files);
const manifest = checkIdentity(archive, metadata, files);
checkCompatibility(metadata, manifest, files, options.platform);
const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
try {
// The candidate alone, in a scratch inventory of its own: never module storage.
const modulesDirectory = path.join(scratch, "candidates");
await mkdir(modulesDirectory, { mode: 0o755 });
await writeTree(path.join(modulesDirectory, metadata.module.id), archive);
const discoveryOptions = {
moduleSdkVersion: options.platform.moduleSdkVersion,
modulesDirectory,
modulesDirectoryRequired: true,
platformVersion: options.platform.platformVersion,
} as const;
let entrypoints: Record<string, string>;
try {
// The same static validation the platform runs, against this package alone. A module that
// requires another module is validated with its dependencies when it is installed.
const [discovered] = await discoverModules(discoveryOptions);
if (discovered === undefined) {
fail("MODULE_PACKAGE_MODULE_INVALID");
}
await discoverModuleMigrations(discoveryOptions);
entrypoints = {
server: discovered.entrypoints.server,
...(discovered.entrypoints.schema ? { schema: discovered.entrypoints.schema } : {}),
...(discovered.entrypoints.jobs ? { jobs: discovered.entrypoints.jobs } : {}),
};
} catch (error) {
if (error instanceof ModulePackageVerificationError) {
throw error;
}
fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error));
}
// Executed only now, and first in isolation: a schema is imported in this process below only
// after the probe proved it loads on its own.
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
try {
await discoverModuleSchemas(discoveryOptions);
} catch (error) {
fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error));
}
if (options.extractTo !== undefined) {
const extractTo = options.extractTo;
let created = false;
try {
await mkdir(extractTo, { mode: 0o755 });
created = true;
await rm(extractTo, { recursive: true });
await writeTree(extractTo, archive);
} catch {
// Never leave a partial module directory behind; never remove one this call did not create.
if (created) {
await rm(extractTo, { force: true, recursive: true }).catch(() => undefined);
}
fail("MODULE_PACKAGE_EXTRACTION_FAILED");
}
}
} finally {
await rm(scratch, { force: true, recursive: true });
}
return Object.freeze({
archiveSha256,
fileCount: metadata.files.length,
moduleId: metadata.module.id,
target: metadata.target,
version: metadata.module.version,
});
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Top-level names the platform owns, which therefore can never be a module id (ADR 0057).
*
* Every module with a Dashboard UI is addressed at `/<module-id>`, beside the platform's own
* top-level namespaces in one URL space. A module called `settings` would collide with the
* Dashboard's Settings pages, and one called `api` with the API itself. This is the complete set the
* platform serves today, not a guess at what it might want later:
*
* - `api` — the API: identity, principal, health, Core resources, module routes, module UI artifacts,
* display descriptors, and realtime (`/api/…`);
* - `assets` — the Dashboard's and display host's scripts (`/assets/ekklesia-dashboard/…`,
* `/assets/ekklesia-display/…`);
* - `display` — the display host's pages and service worker (`/display/…`);
* - `settings` and `account` — the Dashboard's own Settings and account areas.
*
* `apps/api` holds the list to the routes it actually registers, and to the Dashboard namespaces the
* shell interprets, so a new top-level platform route cannot be added without reserving it here.
* Reserving a name is a breaking change for any installation with a module of that id, so the set
* grows only deliberately.
*/
export const RESERVED_MODULE_IDS = Object.freeze([
"account",
"api",
"assets",
"display",
"settings",
] as const);
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);
export function isReservedModuleId(moduleId: string): boolean {
return reserved.has(moduleId);
}
+68 -26
View File
@@ -7,15 +7,16 @@ import {
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleConfiguration,
type ModuleDatabaseExecutor,
type ModuleHttpRouteRegistrar,
type ModuleId,
ModuleRealtimeError,
type ModuleRealtimePublisher,
type ModuleRegistrationContext,
type ModuleRouteDefinitionInput,
type ModuleRouteHandler,
type ModuleRuntimeContext,
type ModuleRuntimeDefinition,
type ModuleRuntimeServices,
type ModuleStorage,
moduleRouteShapeKey,
moduleRoutesOverlap,
@@ -24,6 +25,7 @@ import {
type RegisteredModuleHttpRoute,
safeParseModuleRouteDefinition,
} from "@ekklesia/contracts";
import type { AnyModuleRuntimeServices, ModuleDatabase } from "./database.js";
import {
type DiscoveredModule,
discoverModules,
@@ -68,6 +70,7 @@ export type ModuleRuntimeIssueCode =
| "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
| "MODULE_RUNTIME_EXPORT_INVALID"
| "MODULE_RUNTIME_DATABASE_MISMATCH"
| "MODULE_RUNTIME_ID_MISMATCH"
| "MODULE_RUNTIME_IMPORT_FAILED"
| "MODULE_RUNTIME_VERSION_MISMATCH"
@@ -105,9 +108,8 @@ export interface StartedModuleRuntime {
/**
* Per-module frozen configuration snapshots, keyed by module id (ADR 0029).
*
* A lookup rather than a field on `ModuleRuntimeServices`, because `services` is deliberately **one
* shared object identity every module receives** — a property this runtime has a test for — while
* configuration is per-module by definition. A module with no entry receives
* A lookup rather than a field on `ModuleRuntimeServices`, because configuration is resolved and
* frozen per module independently of its live service object. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and
* module code never branches on whether the platform supplied one.
*/
@@ -124,12 +126,33 @@ function configurationFor(
* Per-module scoped object storage, keyed by module id (ADR 0030).
*
* A lookup for the same reason configuration is one, and emphatically **not** a field on
* `ModuleRuntimeServices`: `services` is one shared object identity every module receives, while a
* storage facade is scoped to exactly the buckets one module declared. Sharing it would hand every
* `ModuleRuntimeServices`: a storage facade is scoped to exactly the buckets one module declared.
* Sharing it would hand every
* module every other module's storage.
*/
export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
/** One frozen service object per module id. Legacy modules may point at objects wrapping the same
* facade; ABI-v1 modules each point at their own attributed executor. */
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
function servicesFor(
services: ModuleRuntimeServicesSource,
module: DiscoveredModule,
): AnyModuleRuntimeServices {
const selected = "get" in services ? services.get(module.manifest.id) : services;
if (!selected) {
throw new TypeError(`Module ${module.manifest.id} has no runtime services.`);
}
const expectsAbi = module.manifest.database?.abi === 1;
const isAbi = "abiVersion" in selected.database && selected.database.abiVersion === 1;
if (expectsAbi !== isAbi) {
throw new TypeError(`Module ${module.manifest.id} has the wrong database service.`);
}
return selected;
}
function storageFor(storages: ModuleStorageLookup, moduleId: string): ModuleStorage {
return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE;
}
@@ -164,10 +187,9 @@ export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOpt
export interface StartModuleRuntimeOptions
extends ModuleDiscoveryOptions,
ModuleRuntimeCompositionOptions {
/** The same frozen services object every started module's `start`/`stop` hooks and (once
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
readonly services: ModuleRuntimeServices;
/** The manifest-selected service object for each module. One module's `start`/`stop` hooks and
* handlers receive its same entry; ABI-v1 entries carry distinct attributed executors. */
readonly services: ModuleRuntimeServicesSource;
}
interface LoadedModule {
@@ -175,6 +197,13 @@ interface LoadedModule {
readonly discovered: DiscoveredModule;
}
type AnyRegistrationHook = (
context: ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => unknown;
type AnyRuntimeHook = (
context: ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => unknown;
interface CapabilityRegistrationStage {
readonly capabilities: CapabilityRegistrar;
commit(): readonly ModuleRuntimeIssue[];
@@ -316,11 +345,11 @@ class StagedCapabilityRegistry {
createRuntimeContext(
module: DiscoveredModule,
phase: "rollback" | "start" | "stop",
services: ModuleRuntimeServices,
services: AnyModuleRuntimeServices,
config: ModuleConfiguration,
storage: ModuleStorage,
realtime: ModuleRealtimePublisher,
): ModuleRuntimeContext {
): ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor> {
const capabilities: CapabilityResolver = Object.freeze({
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
this.#assertAccess(module, capabilityId, phase);
@@ -469,7 +498,7 @@ class StagedModuleHttpRoutes {
local.push(
Object.freeze({
bodyMode: parsed.data.bodyMode,
handler: parsed.data.handler,
handler: parsed.data.handler as ModuleRouteHandler,
maxBodyBytes: parsed.data.maxBodyBytes,
method: parsed.data.method,
moduleId: module.manifest.id,
@@ -555,6 +584,19 @@ async function loadRuntimeDefinitions(
),
);
}
const runtimeDeclaresAbi = definition.database?.abi === 1;
const manifestDeclaresAbi = discovered.manifest.database?.abi === 1;
if (runtimeDeclaresAbi !== manifestDeclaresAbi) {
matchesManifest = false;
issues.push(
runtimeIssue(
"MODULE_RUNTIME_DATABASE_MISMATCH",
"load",
discovered.manifest.id,
`Module runtime database declaration does not match manifest ${discovered.manifest.id}.`,
),
);
}
if (matchesManifest) {
loaded.push({ definition, discovered });
}
@@ -580,15 +622,15 @@ async function registerModules(
module.discovered.manifest.id,
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
);
const context: ModuleRegistrationContext = Object.freeze({
const context = Object.freeze({
capabilities: capabilityStage.capabilities,
config: configurationFor(configurations, module.discovered.manifest.id),
http: routeStage.http,
moduleId: module.discovered.manifest.id,
realtime: realtimeStage.realtime,
});
}) as ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>;
try {
await module.definition.register?.(context);
await (module.definition.register as AnyRegistrationHook | undefined)?.(context);
} catch (error) {
if (error instanceof ModuleRuntimeSignal) {
throw new ModuleRuntimeError([error.issue]);
@@ -685,7 +727,7 @@ async function stopModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
phase: "rollback" | "stop",
services: ModuleRuntimeServices,
services: ModuleRuntimeServicesSource,
configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry,
@@ -698,7 +740,7 @@ async function stopModules(
// A dependency may keep doing ordinary work while its dependents stop. Deactivate only the
// module whose stop hook is about to run, not every publisher at the beginning of the sequence.
realtimeRegistry.deactivateModule(moduleId);
const stop = module.definition.stop;
const stop = module.definition.stop as AnyRuntimeHook | undefined;
if (!stop) {
// No synthetic callback and no timer for a module that declares no stop hook.
continue;
@@ -710,13 +752,13 @@ async function stopModules(
registry.createRuntimeContext(
module.discovered,
phase,
services,
servicesFor(services, module.discovered),
configurationFor(configurations, moduleId),
storageFor(storages, moduleId),
realtimeRegistry.publisherFor(moduleId),
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
),
),
),
).then(() => undefined),
timeoutMs,
);
if (outcome === "timed-out") {
@@ -750,7 +792,7 @@ async function stopModules(
async function startModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
services: ModuleRuntimeServices,
services: ModuleRuntimeServicesSource,
configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry,
@@ -766,15 +808,15 @@ async function startModules(
for (const module of modules) {
rollbackCandidates.push(module);
try {
await module.definition.start?.(
await (module.definition.start as AnyRuntimeHook | undefined)?.(
registry.createRuntimeContext(
module.discovered,
"start",
services,
servicesFor(services, module.discovered),
configurationFor(configurations, module.discovered.manifest.id),
storageFor(storages, module.discovered.manifest.id),
realtimeRegistry.publisherFor(module.discovered.manifest.id),
),
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
);
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
if (missingSnapshots.length > 0) {
@@ -831,7 +873,7 @@ async function startModules(
*/
export async function startDiscoveredModuleRuntime(
discoveredModules: readonly DiscoveredModule[],
services: ModuleRuntimeServices,
services: ModuleRuntimeServicesSource,
timeouts: ModuleRuntimeCompositionOptions = {},
): Promise<StartedModuleRuntime> {
const modules = await loadRuntimeDefinitions(discoveredModules);
+132 -12
View File
@@ -1,12 +1,16 @@
import { pathToFileURL } from "node:url";
import {
type ModuleId,
type ModuleSchemaDefinition,
type ModuleSchemaMetadataDefinition,
moduleTableLocalNameSchema,
moduleTablePrefix,
parseModuleSchemaDefinition,
parseModuleSchemaMetadataDefinition,
physicalModuleTableNameSchema,
} from "@ekklesia/contracts";
import {
type ModuleSchemaDefinition,
parseModuleSchemaDefinition,
} from "./legacy-schema-definition.js";
import { is } from "drizzle-orm";
import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core";
import {
@@ -40,7 +44,9 @@ export interface DiscoveredModuleTable {
readonly exportName: string;
readonly moduleId: ModuleId;
readonly name: string;
readonly table: AnyPgTable;
/** Present only for a temporary legacy-facade module. ABI-v1 schema discovery publishes no ORM
* object and contributes nothing to the legacy aggregate below. */
readonly table?: AnyPgTable;
}
export interface DiscoveredModuleSchema {
@@ -55,21 +61,40 @@ export interface ModuleSchemaAggregate {
readonly tables: readonly DiscoveredModuleTable[];
}
interface LoadedModuleSchema {
interface LoadedLegacyModuleSchema {
readonly definition: ModuleSchemaDefinition;
readonly discovered: DiscoveredModule;
readonly mode: "legacy";
}
interface LoadedAbiModuleSchema {
readonly definition: ModuleSchemaMetadataDefinition;
readonly discovered: DiscoveredModule;
readonly mode: "abi-v1";
}
type LoadedModuleSchema = LoadedLegacyModuleSchema | LoadedAbiModuleSchema;
interface InspectedModuleSchema {
readonly discovered: DiscoveredModule;
readonly tables: ModuleTableCandidate[];
}
interface ModuleTableCandidate extends DiscoveredModuleTable {
interface LegacyModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "legacy";
readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"];
readonly path: string;
readonly table: AnyPgTable;
}
interface AbiModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "abi-v1";
readonly foreignKeys: ModuleSchemaMetadataDefinition["tables"][number]["foreignKeys"];
readonly path: string;
}
type ModuleTableCandidate = LegacyModuleTableCandidate | AbiModuleTableCandidate;
const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias");
export class ModuleSchemaDiscoveryError extends Error {
@@ -156,9 +181,13 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
continue;
}
let definition: ModuleSchemaDefinition;
let definition: ModuleSchemaDefinition | ModuleSchemaMetadataDefinition;
const mode = discovered.manifest.database?.abi === 1 ? "abi-v1" : "legacy";
try {
definition = parseModuleSchemaDefinition(defaultExport(moduleNamespace));
definition =
mode === "abi-v1"
? parseModuleSchemaMetadataDefinition(defaultExport(moduleNamespace))
: parseModuleSchemaDefinition(defaultExport(moduleNamespace));
} catch {
issues.push(
issue(
@@ -182,7 +211,11 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
continue;
}
loaded.push({ definition, discovered });
loaded.push(
mode === "abi-v1"
? { definition: definition as ModuleSchemaMetadataDefinition, discovered, mode }
: { definition: definition as ModuleSchemaDefinition, discovered, mode },
);
}
return { issues, loaded };
@@ -197,6 +230,58 @@ function inspectTables(
for (const loaded of loadedSchemas) {
const moduleId = loaded.discovered.manifest.id;
if (loaded.mode === "abi-v1") {
const candidates: AbiModuleTableCandidate[] = [];
loaded.definition.tables.forEach((table, index) => {
const path = `tables.${index}`;
const candidate: AbiModuleTableCandidate = {
exportName: table.name,
foreignKeys: table.foreignKeys,
mode: "abi-v1",
moduleId,
name: table.name,
path,
};
const tablePrefix = moduleTablePrefix(moduleId);
if (!table.name.startsWith(tablePrefix)) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
moduleId,
`Table ${table.name} is not namespaced for module ${moduleId}.`,
{ path, tableName: table.name },
),
);
} else if (
!moduleTableLocalNameSchema.safeParse(table.name.slice(tablePrefix.length)).success
) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAME_INVALID",
moduleId,
`Module ${moduleId} has an invalid local table name.`,
{ path },
),
);
}
const existing = firstTableByName.get(table.name);
if (existing) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_DUPLICATE",
moduleId,
`Table ${table.name} is declared more than once.`,
{ path, tableName: table.name },
),
);
} else {
firstTableByName.set(table.name, candidate);
}
candidates.push(candidate);
});
inspectedModules.push({ discovered: loaded.discovered, tables: candidates });
continue;
}
const seenTables = new Set<AnyPgTable>();
const candidates: ModuleTableCandidate[] = [];
const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) =>
@@ -293,6 +378,7 @@ function inspectTables(
const candidate: ModuleTableCandidate = {
exportName,
foreignKeys: config.foreignKeys,
mode: "legacy",
moduleId,
name: config.name,
path,
@@ -321,8 +407,8 @@ function inspectTables(
}
function validForeignKeyTarget(
foreignKey: ModuleTableCandidate["foreignKeys"][number],
candidate: ModuleTableCandidate,
foreignKey: LegacyModuleTableCandidate["foreignKeys"][number],
candidate: LegacyModuleTableCandidate,
): AnyPgTable | undefined {
try {
const reference = foreignKey.reference();
@@ -356,14 +442,42 @@ function inspectForeignKeys(
const ownerByTable = new Map<AnyPgTable, ModuleId>();
for (const inspected of inspectedModules) {
for (const candidate of inspected.tables) {
if (candidate.mode === "legacy") {
ownerByTable.set(candidate.table, candidate.moduleId);
}
}
}
for (const inspected of inspectedModules) {
const moduleId = inspected.discovered.manifest.id;
const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table));
for (const candidate of inspected.tables) {
if (candidate.mode === "abi-v1") {
const ownedNames = new Set(inspected.tables.map((table) => table.name));
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
if (!foreignKey.table.startsWith(moduleTablePrefix(moduleId))) {
issues.push(
issue(
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
moduleId,
`Table ${candidate.name} has a foreign key outside module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
} else if (!ownedNames.has(foreignKey.table)) {
issues.push(
issue(
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
moduleId,
`Table ${candidate.name} references a table not exported by module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
}
});
continue;
}
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
const foreignTable = validForeignKeyTarget(foreignKey, candidate);
@@ -446,7 +560,9 @@ function publishAggregate(
moduleId: inspected.discovered.manifest.id,
tables: Object.freeze(
inspected.tables
.map(({ foreignKeys: _foreignKeys, path: _path, ...table }) => Object.freeze(table))
.map(({ foreignKeys: _foreignKeys, mode: _mode, path: _path, ...table }) =>
Object.freeze(table),
)
.sort((left, right) => compareText(left.exportName, right.exportName)),
),
}),
@@ -458,7 +574,11 @@ function publishAggregate(
.sort((left, right) => compareText(left.name, right.name)),
);
const drizzleSchema = Object.freeze(
Object.fromEntries(tables.map((table) => [table.name, table.table] as const)),
Object.fromEntries(
tables.flatMap((table) =>
table.table === undefined ? [] : [[table.name, table.table] as const],
),
),
);
return Object.freeze({ discoveredModules, drizzleSchema, modules, tables });
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
const poolConstructor = vi.hoisted(() => vi.fn());
@@ -7,7 +8,9 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
describe("bootstrap-platform-owner CLI import safety", () => {
const originalEnv = { ...process.env };
it("does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module", async () => {
it(
"does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module",
async () => {
delete process.env.DATABASE_URL;
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
@@ -18,5 +21,7 @@ describe("bootstrap-platform-owner CLI import safety", () => {
expect(poolConstructor).not.toHaveBeenCalled();
process.env = originalEnv;
});
},
IMPORT_SAFETY_TEST_TIMEOUT_MS,
);
});
+7 -2
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
const poolConstructor = vi.hoisted(() => vi.fn());
@@ -7,7 +8,9 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
describe("create-initial-owner CLI import safety", () => {
const originalEnv = { ...process.env };
it("does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module", async () => {
it(
"does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module",
async () => {
delete process.env.DATABASE_URL;
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
@@ -18,7 +21,9 @@ describe("create-initial-owner CLI import safety", () => {
expect(poolConstructor).not.toHaveBeenCalled();
process.env = originalEnv;
});
},
IMPORT_SAFETY_TEST_TIMEOUT_MS,
);
it("reads no environment variable for the password", async () => {
// The password is prompted for or read from standard input; it is deliberately never taken from
+1 -1
View File
@@ -91,7 +91,7 @@ async function createModule(
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
: {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: fixture.name,
version: "1.0.0",
}),
+1 -1
View File
@@ -83,7 +83,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
},
entrypoints: { server: "./dist/server.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
storage: { buckets: [] },
+7 -2
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
const discoverModules = vi.fn();
const discoverModuleSchemas = vi.fn();
@@ -22,7 +23,9 @@ vi.mock("../src/migrations/index.js", async (importOriginal) => ({
}));
describe("Doctor import safety", () => {
it("does not discover, connect, plan, or print when imported", async () => {
it(
"does not discover, connect, plan, or print when imported",
async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
@@ -41,5 +44,7 @@ describe("Doctor import safety", () => {
log.mockRestore();
error.mockRestore();
});
},
IMPORT_SAFETY_TEST_TIMEOUT_MS,
);
});
+10 -4
View File
@@ -1,8 +1,8 @@
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
import {
@@ -32,7 +32,11 @@ const checksumA = "a".repeat(64);
const checksumB = "b".repeat(64);
const checksumC = "c".repeat(64);
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
function discoveredModule(
id: string,
version = "0.1.0",
database?: { readonly abi: 1 },
): DiscoveredModule {
return {
contributions: [],
directory: `/unpublished/${id}`,
@@ -41,13 +45,14 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
configuration: [],
...(database ? { database } : {}),
dependencies: {
capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: {} },
},
entrypoints: { server: "./dist/server.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
storage: { buckets: [] },
@@ -294,7 +299,7 @@ describe("Ekklesia Doctor outbound email reporting", () => {
describe("Ekklesia Doctor diagnostics", () => {
it("publishes a healthy deterministic static report without database configuration", async () => {
const people = discoveredModule("people");
const calendar = discoveredModule("calendar", "0.2.0");
const calendar = discoveredModule("calendar", "0.2.0", { abi: 1 });
const inventory = migrationAggregate(
[migration("people", "0000_initial", checksumA)],
[people, calendar],
@@ -327,6 +332,7 @@ describe("Ekklesia Doctor diagnostics", () => {
]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
expect(check(report, "schemas.discovery")).toMatchObject({ status: "pass" });
expect(check(report, "modules.permissions")).toMatchObject({
details: ["0 permissions registered"],
status: "pass",
@@ -1,12 +1,12 @@
{
"manifestVersion": 7,
"manifestVersion": 8,
"id": "db-facade",
"name": "Database Facade Integration Test Module",
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
"version": "0.1.0",
"compatibility": {
"platform": "^0.1.0",
"moduleSdk": "^0.1.0"
"platform": "^0.2.0",
"moduleSdk": "^0.2.0"
},
"permissions": [
{
@@ -0,0 +1,34 @@
import { alias, getTableConfig, integer, pgTable } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import { parseModuleSchemaDefinition } from "../src/modules/legacy-schema-definition.js";
describe("the legacy module schema definition", () => {
it("normalizes and freezes a typed schema definition without freezing its tables", () => {
const records = pgTable("example_records", { id: integer().primaryKey() });
const definition = parseModuleSchemaDefinition({
moduleId: "example",
tables: { records },
});
expect(definition.tables.records).toBe(records);
expect(getTableConfig(records).name).toBe("example_records");
expect(Object.isFrozen(definition)).toBe(true);
expect(Object.isFrozen(definition.tables)).toBe(true);
expect(Object.isFrozen(records)).toBe(false);
});
it("rejects malformed, empty, unknown, and non-PostgreSQL table definitions", () => {
const records = pgTable("example_records", { id: integer() });
const disguisedRecords = alias(records, "example_disguised_records");
expect(() => parseModuleSchemaDefinition({ moduleId: "example", tables: {} })).toThrow();
expect(() =>
parseModuleSchemaDefinition({ moduleId: "example", tables: { records: {} } }),
).toThrow();
expect(() =>
parseModuleSchemaDefinition({ moduleId: "example", tables: { records }, extra: true }),
).toThrow();
expect(() =>
parseModuleSchemaDefinition({ moduleId: "example", tables: { disguisedRecords } }),
).toThrow();
});
});
@@ -17,7 +17,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
@@ -194,7 +194,7 @@ afterAll(async () => {
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
return parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
+8 -8
View File
@@ -21,7 +21,7 @@ function manifestWith(
id = "announcements",
) {
return parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
],
])("rejects %s", (_label, declaration) => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
"valueOf",
])("rejects the reserved object property name %s as a configuration key", (key) => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
"",
])("rejects the malformed configuration key %s", (key) => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
File diff suppressed because it is too large Load Diff
+867
View File
@@ -0,0 +1,867 @@
import { readFile } from "node:fs/promises";
import {
isModuleDatabaseError,
type ModuleDatabaseErrorCode,
type ModuleDatabaseTransaction,
} from "@ekklesia/contracts";
import { DatabaseError, type Pool } from "pg";
import { describe, expect, it, vi } from "vitest";
import {
createModuleDatabaseExecutor,
moduleDatabaseExecutorModuleId,
} from "../src/modules/database-abi.js";
import {
decodeModuleDatabaseValue,
encodeModuleDatabaseParameters,
MODULE_DATABASE_MAX_PARAMETERS,
parsePostgresArrayText,
} from "../src/modules/database-abi-values.js";
/**
* Module Database ABI v1 (ADR 0050) without a database: parameter validation and encoding, result
* decoding from PostgreSQL text, option validation, error sanitization, and scope bookkeeping.
*
* Everything that depends on PostgreSQL's own behaviour (type OIDs, the protocol, SQLSTATEs,
* transactions, savepoints, session settings) is proven against a real database in
* `module-database-abi.integration.ts`. The scripted client here only replays command tags and
* transaction status, so the executor's own bookkeeping can be observed statement by statement.
*/
async function rejection(promise: Promise<unknown>): Promise<unknown> {
try {
await promise;
} catch (error) {
return error;
}
throw new Error("Expected the operation to reject.");
}
async function expectAbiError(
promise: Promise<unknown>,
code: ModuleDatabaseErrorCode,
sqlState?: string,
): Promise<void> {
const error = await rejection(promise);
expect(isModuleDatabaseError(error)).toBe(true);
expect(error).toMatchObject({ code });
expect((error as { sqlState?: string }).sqlState).toBe(sqlState);
}
function syncThrows(run: () => unknown): unknown {
try {
run();
} catch (error) {
return error;
}
throw new Error("Expected the call to throw.");
}
// --- Parameters --------------------------------------------------------------------------------
describe("encodeModuleDatabaseParameters", () => {
it("encodes every scalar category as text, or bytes, and nothing else", () => {
const bytes = new Uint8Array([0, 1, 255]);
const encoded = encodeModuleDatabaseParameters([
null,
true,
false,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
9_007_199_254_740_993n,
'it\'s \\ "quoted"',
bytes,
]);
expect(encoded.slice(0, 9)).toEqual([
null,
"true",
"false",
"1.5",
"NaN",
"Infinity",
"-Infinity",
"9007199254740993",
'it\'s \\ "quoted"',
]);
expect(Buffer.isBuffer(encoded[9])).toBe(true);
expect([...(encoded[9] as Buffer)]).toEqual([0, 1, 255]);
});
it("copies bytes, so a later change by the caller cannot reach the statement", () => {
const bytes = new Uint8Array([1, 2, 3]);
const view = bytes.subarray(1);
const [encoded] = encodeModuleDatabaseParameters([view]);
bytes[1] = 9;
expect([...(encoded as Buffer)]).toEqual([2, 3]);
});
it("encodes arrays as quoted PostgreSQL array literals, nested for more dimensions", () => {
expect(
encodeModuleDatabaseParameters([
[1, null, 3],
['a"b', "c\\d", "", "NULL", "{,}"],
[
[1, 2],
[3, 4],
],
[new Uint8Array([0, 255])],
[true, 2n],
[],
]),
).toEqual([
'{"1",NULL,"3"}',
'{"a\\"b","c\\\\d","","NULL","{,}"}',
'{{"1","2"},{"3","4"}}',
'{"\\\\x00ff"}',
'{"true","2"}',
"{}",
]);
});
it("treats omitted values as no parameters", () => {
expect(encodeModuleDatabaseParameters(undefined)).toEqual([]);
});
class Point {
readonly x = 1;
}
const refused: readonly [string, unknown][] = [
["a Date", new Date(0)],
["a plain object", { a: 1 }],
["a null-prototype object", Object.create(null)],
["undefined", undefined],
["a function", () => 1],
["a symbol", Symbol("s")],
["a class instance", new Point()],
["a Map", new Map()],
["another typed array", new Int16Array([1])],
["an ArrayBuffer", new ArrayBuffer(1)],
["a boxed string", new String("x")],
["an array holding a Date", [new Date(0)]],
["an array holding undefined", [1, undefined]],
["an array holding an object", [{ a: 1 }]],
// biome-ignore lint/suspicious/noSparseArray: the hole is the case under test.
["a sparse array", [1, , 3]],
["scalars mixed with sub-arrays", [1, [2]]],
["a null where a sub-array belongs", [[1], null]],
["ragged sub-arrays", [[1], [1, 2]]],
["more dimensions than PostgreSQL allows", [[[[[[[1]]]]]]]],
];
for (const [label, value] of refused) {
it(`refuses ${label}, without naming the value`, () => {
const error = syncThrows(() => encodeModuleDatabaseParameters(["fine", value]));
expect(isModuleDatabaseError(error)).toBe(true);
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
expect((error as Error).message).not.toMatch(/fine|1970|Point|Symbol/u);
});
}
it("accepts exactly PostgreSQL's six array dimensions", () => {
expect(encodeModuleDatabaseParameters([[[[[[[1]]]]]]])).toEqual(['{{{{{{"1"}}}}}}']);
});
it("refuses a values list that is not an array, or longer than the protocol allows", () => {
for (const values of [
"x",
{ 0: "x", length: 1 },
new Array(MODULE_DATABASE_MAX_PARAMETERS + 1).fill(1),
]) {
const error = syncThrows(() => encodeModuleDatabaseParameters(values));
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
}
expect(
encodeModuleDatabaseParameters(new Array(MODULE_DATABASE_MAX_PARAMETERS).fill(1)),
).toHaveLength(MODULE_DATABASE_MAX_PARAMETERS);
});
it("refuses a self-referencing array instead of recursing forever", () => {
const cyclic: unknown[] = [];
cyclic.push(cyclic);
expect(syncThrows(() => encodeModuleDatabaseParameters([cyclic]))).toMatchObject({
code: "MODULE_DATABASE_PARAMETER_INVALID",
});
});
});
// --- Results -----------------------------------------------------------------------------------
describe("decodeModuleDatabaseValue", () => {
it("decodes the scalar types the table names, and keeps everything else as exact text", () => {
expect(decodeModuleDatabaseValue(16, "t")).toBe(true);
expect(decodeModuleDatabaseValue(16, "f")).toBe(false);
expect(decodeModuleDatabaseValue(21, "-32768")).toBe(-32_768);
expect(decodeModuleDatabaseValue(23, "2147483647")).toBe(2_147_483_647);
expect(decodeModuleDatabaseValue(26, "4294967295")).toBe(4_294_967_295);
expect(decodeModuleDatabaseValue(700, "1.5")).toBe(1.5);
expect(decodeModuleDatabaseValue(701, "NaN")).toBeNaN();
expect(decodeModuleDatabaseValue(701, "Infinity")).toBe(Number.POSITIVE_INFINITY);
expect(decodeModuleDatabaseValue(701, "-Infinity")).toBe(Number.NEGATIVE_INFINITY);
expect(decodeModuleDatabaseValue(701, "1e+300")).toBe(1e300);
// Precision-sensitive and zone-sensitive types stay exactly as PostgreSQL wrote them.
expect(decodeModuleDatabaseValue(20, "9223372036854775807")).toBe("9223372036854775807");
expect(decodeModuleDatabaseValue(1700, "0.1000000000000000000001")).toBe(
"0.1000000000000000000001",
);
expect(decodeModuleDatabaseValue(3802, '{"a": 12345678901234567890}')).toBe(
'{"a": 12345678901234567890}',
);
expect(decodeModuleDatabaseValue(1184, "2026-10-07 12:00:00+00")).toBe(
"2026-10-07 12:00:00+00",
);
// An unknown OID (an enum, a domain, an extension type) is text.
expect(decodeModuleDatabaseValue(987_654, "anything")).toBe("anything");
});
it("decodes bytea to a plain Uint8Array, from hex or the older escape form", () => {
const hex = decodeModuleDatabaseValue(17, "\\x00ff10");
expect(Object.getPrototypeOf(hex)).toBe(Uint8Array.prototype);
expect([...(hex as Uint8Array)]).toEqual([0, 255, 16]);
expect([...(decodeModuleDatabaseValue(17, "\\x") as Uint8Array)]).toEqual([]);
const escaped = decodeModuleDatabaseValue(17, "a\\000\\\\\\377");
expect([...(escaped as Uint8Array)]).toEqual([97, 0, 92, 255]);
});
it("decodes built-in arrays element by element", () => {
expect(decodeModuleDatabaseValue(1016, "{1,NULL,9223372036854775807}")).toEqual([
"1",
null,
"9223372036854775807",
]);
expect(decodeModuleDatabaseValue(1231, "{1.10,2}")).toEqual(["1.10", "2"]);
expect(decodeModuleDatabaseValue(1000, "{t,f}")).toEqual([true, false]);
expect(decodeModuleDatabaseValue(1007, "{{1,2},{3,4}}")).toEqual([
[1, 2],
[3, 4],
]);
const bytes = decodeModuleDatabaseValue(1001, '{"\\\\x00ff"}') as Uint8Array[];
expect([...(bytes[0] as Uint8Array)]).toEqual([0, 255]);
expect(decodeModuleDatabaseValue(1009, "{}")).toEqual([]);
// An array of a type the table does not know arrives as text, like the type itself.
expect(decodeModuleDatabaseValue(987_655, "{a,b}")).toBe("{a,b}");
});
});
describe("parsePostgresArrayText", () => {
const text = (value: string) => value;
it('honours quoting, escapes, and the difference between NULL and "NULL"', () => {
expect(parsePostgresArrayText('{"a\\"b","c,d",NULL,"NULL","","{}",null}', text)).toEqual([
'a"b',
"c,d",
null,
"NULL",
"",
"{}",
null,
]);
expect(parsePostgresArrayText('{"back\\\\slash"}', text)).toEqual(["back\\slash"]);
});
it("drops a non-default lower bound: a JavaScript array is a zero-based list", () => {
expect(parsePostgresArrayText("[0:1]={5,6}", text)).toEqual(["5", "6"]);
expect(parsePostgresArrayText("[1:1][2:3]={{7,8}}", text)).toEqual([["7", "8"]]);
});
it("refuses text that is not an array literal", () => {
for (const malformed of ["", "{", "{1", '{"a', "{1}x", "[1:2]{1,2}", "1"]) {
expect(() => parsePostgresArrayText(malformed, text)).toThrow();
}
});
});
// --- The executor, over a scripted client -------------------------------------------------------
interface ScriptedStatement {
readonly text: string;
readonly values?: readonly unknown[];
}
interface Script {
readonly pool: Pool;
readonly statements: ScriptedStatement[];
readonly released: (boolean | undefined)[];
/** Resolve a statement only when `release` is called, to model in-flight work. */
holdNext(): () => void;
failNext(error: unknown): void;
/** Fail the next statement whose text is exactly `text`. */
failWhen(text: string, error: unknown): void;
connectCount(): number;
}
function commandOf(text: string): string {
const word = /^\s*([A-Za-z]+)/u.exec(text)?.[1]?.toUpperCase() ?? "";
return word === "END" ? "COMMIT" : word;
}
/**
* A pool whose one client replays PostgreSQL's command tags and transaction status for simple SQL.
* It is not PostgreSQL: it exists only so the executor's bookkeeping can be observed.
*/
function scriptedPool(): Script {
const statements: ScriptedStatement[] = [];
const released: (boolean | undefined)[] = [];
let status = "I";
let connects = 0;
let hold: Promise<void> | undefined;
let failure: unknown;
const failures = new Map<string, unknown>();
const client = {
getTransactionStatus: () => status,
on: () => client,
removeListener: () => client,
async query(config: string | { text: string; values?: unknown[] }) {
const text = typeof config === "string" ? config : config.text;
statements.push(
typeof config === "string" ? { text } : { text, values: config.values ?? [] },
);
const held = hold;
hold = undefined;
if (held !== undefined) {
await held;
}
if (failure !== undefined || failures.has(text)) {
const error = failure ?? failures.get(text);
failure = undefined;
failures.delete(text);
if (status === "T") {
status = "E";
}
throw error;
}
const command = commandOf(text);
if (command === "BEGIN" || command === "START") {
status = "T";
} else if (command === "COMMIT" || (command === "ROLLBACK" && !/\bTO\b/iu.test(text))) {
const aborted = status === "E";
status = "I";
return { command: aborted ? "ROLLBACK" : command, fields: [], rowCount: null, rows: [] };
}
return {
command,
fields: [{ dataTypeID: 23, name: "n" }],
rowCount: 1,
rows: [["1"]],
};
},
release: (destroy?: boolean) => {
released.push(destroy);
},
};
return {
connectCount: () => connects,
failNext(error) {
failure = error;
},
failWhen(text, error) {
failures.set(text, error);
},
holdNext() {
let release!: () => void;
hold = new Promise<void>((resolve) => {
release = resolve;
});
return release;
},
pool: {
connect: vi.fn(async () => {
connects += 1;
return client;
}),
} as unknown as Pool,
released,
statements,
};
}
function pgError(code: string): DatabaseError {
const error = new DatabaseError(
'duplicate key value violates unique constraint "example_secret_key"',
100,
"error",
);
error.code = code;
error.detail = "Key (secret)=(hunter2) already exists.";
error.hint = "a hint";
error.table = "example_records";
error.constraint = "example_secret_key";
return error;
}
const pin =
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
function texts(script: Script): string[] {
return script.statements.map((statement) => statement.text);
}
describe("createModuleDatabaseExecutor", () => {
it("builds a frozen executor holding exactly the ABI's members, without touching the pool", () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
expect(Object.keys(executor).sort()).toEqual(["abiVersion", "query", "transaction"]);
expect(executor.abiVersion).toBe(1);
expect(Object.isFrozen(executor)).toBe(true);
expect(Object.getPrototypeOf(executor)).toBe(Object.prototype);
for (const forbidden of ["pool", "client", "$client", "_", "session", "dialect", "select"]) {
expect(forbidden in executor).toBe(false);
}
expect(script.connectCount()).toBe(0);
});
it("carries its module for Core's attribution, and refuses an invalid module ID", () => {
const executor = createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "care-team" });
expect(moduleDatabaseExecutorModuleId(executor)).toBe("care-team");
expect(moduleDatabaseExecutorModuleId({})).toBeUndefined();
expect(() =>
createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "Not A Module" }),
).toThrow(TypeError);
});
it("pins the session, runs one extended-protocol statement, and resets before releasing", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const result = await executor.query({ text: "SELECT 1 AS n", values: [5n] });
expect(result).toEqual({
command: "SELECT",
fields: [{ name: "n", typeOid: 23 }],
rowCount: 1,
rows: [{ n: 1 }],
});
expect(texts(script)).toEqual([pin, "SELECT 1 AS n", "DISCARD ALL"]);
expect(script.statements[1]).toEqual({ text: "SELECT 1 AS n", values: ["5"] });
expect(script.released).toEqual([undefined]);
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.rows)).toBe(true);
expect(Object.isFrozen(result.fields)).toBe(true);
});
it("refuses a malformed request before borrowing a connection", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const query = executor.query as (request: unknown) => Promise<unknown>;
for (const request of [
undefined,
null,
"SELECT 1",
[],
{ text: "" },
{ text: 1 },
{ text: "SELECT 1", rowMode: "rows" },
{ text: "SELECT 1", name: "prepared" },
{ text: "SELECT 1", types: [] },
]) {
await expectAbiError(query(request), "MODULE_DATABASE_REQUEST_INVALID");
}
await expectAbiError(
query({ text: "SELECT $1", values: [new Date()] }),
"MODULE_DATABASE_PARAMETER_INVALID",
);
expect(script.connectCount()).toBe(0);
});
it("reports only PostgreSQL's SQLSTATE from a database error, never its text", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
script.failWhen("SELECT 1", pgError("23505"));
const error = await rejection(executor.query({ text: "SELECT 1" }));
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED", sqlState: "23505" });
const serialized = `${JSON.stringify(error)} ${String(error)} ${(error as Error).stack}`;
for (const secret of [
"hunter2",
"example_secret_key",
"example_records",
"duplicate",
"hint",
]) {
expect(serialized).not.toContain(secret);
}
expect((error as Error).cause).toBeUndefined();
// A PostgreSQL error leaves the connection usable, so it is reset and returned.
expect(script.released).toEqual([undefined]);
});
it("drops a non-PostgreSQL failure's code and destroys the connection it happened on", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
script.failWhen(
"SELECT 1",
Object.assign(new Error("Connection terminated: postgres://u:p@h/db"), { code: "EPIPE" }),
);
const error = await rejection(executor.query({ text: "SELECT 1" }));
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED" });
expect((error as { sqlState?: string }).sqlState).toBeUndefined();
expect(JSON.stringify(error)).not.toContain("postgres://");
expect(script.released).toEqual([true]);
});
it("refuses a top-level transaction-control statement and destroys a connection it left open", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await expectAbiError(executor.query({ text: "BEGIN" }), "MODULE_DATABASE_REQUEST_INVALID");
expect(script.released).toEqual([true]);
expect(texts(script)).not.toContain("DISCARD ALL");
});
});
describe("transaction options", () => {
const invalid: readonly unknown[] = [
null,
"serializable",
[],
{ isolation: "read uncommitted" },
{ isolation: "SERIALIZABLE" },
{ isolation: "constructor" },
{ readOnly: "yes" },
{ readOnly: 1 },
{ deferrable: true },
{ isolation: "serializable", retries: 3 },
];
for (const options of invalid) {
it(`refuses ${JSON.stringify(options)} before borrowing a connection`, async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const callback = vi.fn();
await expectAbiError(
executor.transaction(callback, options as never),
"MODULE_DATABASE_TRANSACTION_OPTION_INVALID",
);
expect(callback).not.toHaveBeenCalled();
expect(script.connectCount()).toBe(0);
});
}
it("refuses a callback that is not a function", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await expectAbiError(
executor.transaction("SELECT 1" as never),
"MODULE_DATABASE_REQUEST_INVALID",
);
expect(script.connectCount()).toBe(0);
});
it("begins every transaction with an explicit isolation level and access mode", async () => {
const cases: readonly [unknown, string][] = [
[undefined, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
[{}, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
[{ readOnly: true }, "BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY"],
[{ isolation: "repeatable read" }, "BEGIN ISOLATION LEVEL REPEATABLE READ READ WRITE"],
[
{ isolation: "serializable", readOnly: false },
"BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE",
],
];
for (const [options, begin] of cases) {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async () => undefined, options as never);
expect(texts(script)).toEqual([pin, begin, "COMMIT", "DISCARD ALL"]);
}
});
});
describe("transaction scopes", () => {
it("commits a resolved callback and returns its value", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const value = await executor.transaction(async (tx) => {
expect(Object.keys(tx).sort()).toEqual(["abiVersion", "query", "savepoint"]);
expect(Object.isFrozen(tx)).toBe(true);
expect(moduleDatabaseExecutorModuleId(tx)).toBe("example");
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
return "done";
});
expect(value).toBe("done");
expect(texts(script).slice(1)).toEqual([
"BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE",
"INSERT INTO example_records VALUES (1)",
"COMMIT",
"DISCARD ALL",
]);
});
it("rolls back a rejected callback and rejects with the callback's own error, untouched", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("module failure");
const error = await rejection(
executor.transaction(async (tx) => {
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
throw own;
}),
);
expect(error).toBe(own);
expect(texts(script)).toContain("ROLLBACK");
expect(texts(script)).not.toContain("COMMIT");
});
it("revokes the executor when the callback settles; later use sends nothing", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
let escaped: ModuleDatabaseTransaction | undefined;
await executor.transaction((tx) => {
escaped = tx;
});
const sent = script.statements.length;
await expectAbiError(
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
"MODULE_DATABASE_SCOPE_CLOSED",
);
await expectAbiError(
(escaped as ModuleDatabaseTransaction).savepoint(async () => undefined),
"MODULE_DATABASE_SCOPE_CLOSED",
);
expect(script.statements).toHaveLength(sent);
});
it("never commits a callback that returned with a query still outstanding, even once it succeeds", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
let forgotten: Promise<unknown> | undefined;
let releaseHeld: (() => void) | undefined;
const outcome = executor.transaction((tx) => {
releaseHeld = script.holdNext();
forgotten = tx.query({ text: "INSERT INTO example_records VALUES (1)" });
});
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
// Core waits for the started statement to settle before rolling back the connection.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(texts(script)).not.toContain("ROLLBACK");
releaseHeld?.();
await expectAbiError(outcome, "MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
await expect(forgotten).resolves.toBeDefined();
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
expect(texts(script)).not.toContain("COMMIT");
});
it("keeps a rejected callback's own error when sibling work is still settling, and still never commits", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("module failure");
let releaseHeld: (() => void) | undefined;
const outcome = executor.transaction(async (tx) => {
releaseHeld = script.holdNext();
void tx.query({ text: "INSERT INTO example_records VALUES (1)" });
throw own;
});
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
// The sibling statement is drained before Core rolls back.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(texts(script)).not.toContain("ROLLBACK");
releaseHeld?.();
expect(await rejection(outcome)).toBe(own);
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
expect(texts(script)).not.toContain("COMMIT");
});
it("refuses a transaction-control statement inside a transaction and never commits it", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const error = await rejection(
executor.transaction(async (tx) => {
await expectAbiError(
tx.query({ text: "SAVEPOINT mine" }),
"MODULE_DATABASE_REQUEST_INVALID",
);
return "carried on";
}),
);
expect(error).toMatchObject({ code: "MODULE_DATABASE_REQUEST_INVALID" });
expect(texts(script)).not.toContain("COMMIT");
expect(texts(script)).toContain("ROLLBACK");
// The connection is destroyed, never reset and reused.
expect(texts(script)).not.toContain("DISCARD ALL");
expect(script.released).toEqual([true]);
});
it("reports a COMMIT that PostgreSQL answered with ROLLBACK as a failed transaction", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const outcome = executor.transaction(async (tx) => {
script.failNext(pgError("22012"));
await expectAbiError(
tx.query({ text: "SELECT 1/0" }),
"MODULE_DATABASE_QUERY_FAILED",
"22012",
);
});
await expectAbiError(outcome, "MODULE_DATABASE_TRANSACTION_FAILED");
});
});
describe("savepoint scopes", () => {
it("names savepoints itself, releases a resolved one, and rolls back a rejected one", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("savepoint failure");
await executor.transaction(async (tx) => {
await tx.savepoint(async (sp) => {
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
});
expect(await rejection(tx.savepoint(async () => Promise.reject(own)))).toBe(own);
});
expect(texts(script).slice(2, -2)).toEqual([
"SAVEPOINT ekklesia_savepoint_1",
"INSERT INTO example_records VALUES (1)",
"RELEASE SAVEPOINT ekklesia_savepoint_1",
"SAVEPOINT ekklesia_savepoint_2",
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_2",
"RELEASE SAVEPOINT ekklesia_savepoint_2",
]);
});
it("makes the parent inactive while a savepoint is open, so scopes cannot overlap", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async (tx) => {
await tx.savepoint(async (sp) => {
await expectAbiError(tx.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
await expectAbiError(
tx.savepoint(async () => undefined),
"MODULE_DATABASE_SCOPE_INACTIVE",
);
await sp.savepoint(async (inner) => {
await expectAbiError(sp.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
await inner.query({ text: "SELECT 2" });
});
await sp.query({ text: "SELECT 3" });
});
await tx.query({ text: "SELECT 4" });
});
expect(texts(script)).toContain("SAVEPOINT ekklesia_savepoint_2");
expect(texts(script)).toContain("COMMIT");
});
it("revokes an escaped savepoint executor", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async (tx) => {
let escaped: ModuleDatabaseTransaction | undefined;
await tx.savepoint((sp) => {
escaped = sp;
});
await expectAbiError(
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
"MODULE_DATABASE_SCOPE_CLOSED",
);
await tx.query({ text: "SELECT 2" });
});
expect(texts(script)).toContain("COMMIT");
});
it("rolls back, and never releases, a savepoint whose callback left work outstanding", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async (tx) => {
await expectAbiError(
tx.savepoint((sp) => {
const release = script.holdNext();
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
setTimeout(release, 10);
}),
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
);
});
expect(texts(script).slice(2, -2)).toEqual([
"SAVEPOINT ekklesia_savepoint_1",
"INSERT INTO example_records VALUES (1)",
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
]);
});
it("keeps a rejected savepoint callback's own error, rolling back and releasing after its sibling work settles", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("savepoint failure");
await executor.transaction(async (tx) => {
const error = await rejection(
tx.savepoint(async (sp) => {
const release = script.holdNext();
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
setTimeout(release, 10);
throw own;
}),
);
expect(error).toBe(own);
});
expect(texts(script).slice(2, -2)).toEqual([
"SAVEPOINT ekklesia_savepoint_1",
"INSERT INTO example_records VALUES (1)",
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
"RELEASE SAVEPOINT ekklesia_savepoint_1",
]);
expect(texts(script)).toContain("COMMIT");
});
it("counts an unawaited savepoint as the transaction's outstanding work", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
let forgotten: Promise<unknown> | undefined;
await expectAbiError(
executor.transaction((tx) => {
forgotten = tx.savepoint(async (sp) => {
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
});
}),
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
);
await expectAbiError(forgotten as Promise<unknown>, "MODULE_DATABASE_SCOPE_CLOSED");
expect(texts(script)).not.toContain("COMMIT");
expect(texts(script).filter((text) => text.startsWith("RELEASE"))).toEqual([]);
expect(texts(script).at(-1)).toBe("DISCARD ALL");
});
});
describe("the ABI's own sources", () => {
it("import no ORM, query builder, or ORM type", async () => {
for (const file of [
"../src/modules/database-abi.ts",
"../src/modules/database-abi-values.ts",
]) {
const source = await readFile(new URL(file, import.meta.url), "utf8");
const specifiers = [...source.matchAll(/\bfrom\s+"([^"]+)"/gu)].map((match) => match[1]);
expect(
specifiers.every((specifier) =>
["@ekklesia/contracts", "pg", "./database-abi-values.js"].includes(specifier ?? ""),
),
).toBe(true);
expect(source).not.toMatch(/drizzle|kysely|NodePg|PgTable|PgColumn/iu);
}
});
});
@@ -1,8 +1,8 @@
import { integer, pgTable, text } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import type { ModuleDatabase, ModuleRuntimeServices } from "../src/index.js";
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
const testTable = pgTable("contracts_module_database_test", {
const testTable = pgTable("core_module_database_type_test", {
id: integer("id").primaryKey(),
name: text("name").notNull(),
});
@@ -68,16 +68,17 @@ function typeOnlyExclusions(database: ModuleDatabase): void {
}
void typeOnlyExclusions;
/** Never invoked — proves `ModuleRuntimeServices` has exactly one field, `database`, typed as
* `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization field this phase. */
function typeOnlyServicesShape(services: ModuleRuntimeServices): void {
/** Never invoked — proves a legacy module's `ModuleRuntimeServices` has exactly one field,
* `database`, typed as `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization
* field this phase. */
function typeOnlyServicesShape(services: LegacyModuleRuntimeServices): void {
const database: ModuleDatabase = services.database;
void database;
// @ts-expect-error `storage` does not exist on `ModuleRuntimeServices` yet.
// @ts-expect-error `storage` does not exist on the services yet.
void services.storage;
// @ts-expect-error `jobs` does not exist on `ModuleRuntimeServices` yet.
// @ts-expect-error `jobs` does not exist on the services yet.
void services.jobs;
// @ts-expect-error `authorization` does not exist on `ModuleRuntimeServices` yet.
// @ts-expect-error `authorization` does not exist on the services yet.
void services.authorization;
}
void typeOnlyServicesShape;
+49 -1
View File
@@ -4,8 +4,10 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
discoverModules,
isReservedModuleId,
ModuleDiscoveryError,
type ModuleDiscoveryIssueCode,
RESERVED_MODULE_IDS,
} from "../src/modules/index.js";
interface ModuleFixtureOptions {
@@ -34,7 +36,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -271,6 +273,52 @@ describe("module discovery", () => {
);
});
it("rejects a module whose id is one of the platform's reserved top-level names (ADR 0057)", async () => {
// Every module with a Dashboard UI is addressed at `/<module-id>`, so an id equal to a
// platform-owned top-level namespace would collide with the API, the display host, a host
// script, or a Dashboard page.
expect(RESERVED_MODULE_IDS).toEqual(["account", "api", "assets", "display", "settings"]);
expect(Object.isFrozen(RESERVED_MODULE_IDS)).toBe(true);
for (const id of RESERVED_MODULE_IDS) {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, id);
const error = await discoveryError(modulesDirectory);
expect(error.issues, id).toEqual([
expect.objectContaining({
code: "MODULE_ID_RESERVED",
moduleDirectory: id,
moduleId: id,
path: "id",
}),
]);
}
});
it("accepts ids that merely resemble a reserved name", async () => {
const modulesDirectory = await createModulesDirectory();
for (const id of ["settings-sync", "my-account", "displays", "api-docs"]) {
await createModule(modulesDirectory, id);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(modules.map((module) => module.manifest.id).sort()).toEqual([
"api-docs",
"displays",
"my-account",
"settings-sync",
]);
expect(isReservedModuleId("settings")).toBe(true);
expect(isReservedModuleId("settings-sync")).toBe(false);
});
it("enforces platform and Module SDK compatibility", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "future", {
+7 -2
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
const readdir = vi.fn();
const readFile = vi.fn();
@@ -14,7 +15,9 @@ vi.mock("node:fs/promises", async (importOriginal) => ({
}));
describe("module HTTP contract and runtime import safety", () => {
it("performs no filesystem I/O merely by importing the module runtime or the module-http contract", async () => {
it(
"performs no filesystem I/O merely by importing the module runtime or the module-http contract",
async () => {
await import("../src/modules/index.js");
await import("@ekklesia/contracts");
@@ -22,5 +25,7 @@ describe("module HTTP contract and runtime import safety", () => {
expect(readFile).not.toHaveBeenCalled();
expect(realpath).not.toHaveBeenCalled();
expect(stat).not.toHaveBeenCalled();
});
},
IMPORT_SAFETY_TEST_TIMEOUT_MS,
);
});
@@ -200,7 +200,7 @@ afterAll(async () => {
* by eligibility resolution. */
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
+2 -2
View File
@@ -22,7 +22,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -344,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -35,9 +35,9 @@ async function createModulesDirectory(): Promise<string> {
function discoveryOptions(modulesDirectory: string) {
return {
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.0",
};
}
@@ -0,0 +1,748 @@
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
currentModulePackageHost,
type ModulePackageHost,
ModulePackageVerificationError,
type ModulePackageVerificationErrorCode,
verifyModulePackage,
} from "../src/modules/index.js";
import {
type FixtureEntry,
modulePackageFixture,
type PackageFixtureOptions,
sha256,
} from "./support/module-package-fixture.js";
const platform = {
databaseRuntime: { "drizzle-orm": "0.45.2" },
moduleSdkVersion: "0.2.0",
platformVersion: "0.2.0",
} as const;
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
async function scratch(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-"));
temporaryDirectories.push(directory);
return directory;
}
async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
readonly archivePath: string;
readonly sha256: string;
}> {
const fixture = await modulePackageFixture(options);
const archivePath = path.join(await scratch(), "private-host-name-5e1d.tar.gz");
await writeFile(archivePath, fixture.archive);
return { archivePath, sha256: fixture.sha256 };
}
async function executionScratch(): Promise<string> {
const directory = path.join(await scratch(), "execution");
await mkdir(directory);
return directory;
}
async function rejection(
options: PackageFixtureOptions,
code: ModulePackageVerificationErrorCode,
extra: { readonly expectedSha256?: string } = {},
): Promise<ModulePackageVerificationError> {
const { archivePath } = await archiveFile(options);
try {
await verifyModulePackage({ archivePath, platform, ...extra });
} catch (error) {
expect(error).toBeInstanceOf(ModulePackageVerificationError);
const verificationError = error as ModulePackageVerificationError;
expect(verificationError.code).toBe(code);
// Sanitized: never the archive's location, a temporary directory, or text from inside it.
const rendered = JSON.stringify({ ...verificationError, message: verificationError.message });
expect(rendered).not.toContain("private-host-name-5e1d");
expect(rendered).not.toContain(tmpdir());
expect(rendered).not.toContain("attacker-chosen");
return verificationError;
}
throw new Error(`Expected ${code}.`);
}
function withEntry(entry: FixtureEntry): PackageFixtureOptions {
return { extraEntries: [entry] };
}
function abiPackage(overrides: PackageFixtureOptions = {}): PackageFixtureOptions {
return {
manifest: (manifest) => ({ ...manifest, database: { abi: 1 } }),
schemaSource: `export default {
moduleId: "example",
tables: [{ name: "example_records", foreignKeys: [] }]
};\n`,
serverSource:
'export default { database: { abi: 1 }, id: "example", version: "0.1.0", register() {} };\n',
...overrides,
};
}
describe("Module Package v1 verification (ADR 0049)", () => {
it("verifies a valid any-target package, its digest, and its module", async () => {
const { archivePath, sha256: digest } = await archiveFile();
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
expect(result).toEqual({
archiveSha256: digest,
fileCount: 9,
moduleId: "example",
target: "any",
version: "0.1.0",
});
});
it("uses an explicit execution scratch for pure JavaScript and cleans concurrent runs", async () => {
const { archivePath } = await archiveFile();
const executionScratchDirectory = await executionScratch();
const runs = Array.from({ length: 4 }, () =>
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
);
await expect(Promise.all(runs)).resolves.toHaveLength(4);
expect(await readdir(executionScratchDirectory)).toEqual([]);
});
it("cleans its execution scratch after a backend load failure", async () => {
const { archivePath } = await archiveFile({
serverSource:
'import "dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0" };\n',
});
const executionScratchDirectory = await executionScratch();
await expect(
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
).rejects.toMatchObject({ code: "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED" });
expect(await readdir(executionScratchDirectory)).toEqual([]);
});
it("reports a missing execution scratch as a verifier environment error", async () => {
const { archivePath } = await archiveFile();
const executionScratchDirectory = path.join(await scratch(), "missing");
await expect(
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
).rejects.toMatchObject({
code: "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE",
message:
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
});
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
});
it("rejects an unsafe archive before consulting the execution scratch", async () => {
const { archivePath } = await archiveFile(
withEntry({ path: "example/../attacker-chosen", data: "x" }),
);
const executionScratchDirectory = path.join(await scratch(), "missing");
await expect(
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
).rejects.toMatchObject({ code: "MODULE_PACKAGE_UNSAFE_PATH" });
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
});
it("extracts only a verified package, with normalized modes, into a new directory", async () => {
const { archivePath } = await archiveFile();
const target = path.join(await scratch(), "example");
await verifyModulePackage({ archivePath, extractTo: target, platform });
const names = (await readdir(target, { recursive: true })).sort();
expect(names).toContain("ekklesia.package.json");
expect(names).toContain("LICENSE");
expect(names).toContain("dist/server/index.js");
expect((await stat(target)).mode & 0o7777).toBe(0o755);
expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644);
const { archivePath: broken } = await archiveFile({ runtimePackage: { name: "x" } });
const refused = path.join(await scratch(), "refused");
await expect(
verifyModulePackage({ archivePath: broken, extractTo: refused, platform }),
).rejects.toBeInstanceOf(ModulePackageVerificationError);
await expect(stat(refused)).rejects.toMatchObject({ code: "ENOENT" });
});
it("refuses to extract over an existing directory", async () => {
const { archivePath } = await archiveFile();
const existing = await scratch();
await expect(
verifyModulePackage({ archivePath, extractTo: existing, platform }),
).rejects.toMatchObject({ code: "MODULE_PACKAGE_EXTRACTION_FAILED" });
});
it("fails a wrong archive digest", async () => {
await rejection({}, "MODULE_PACKAGE_DIGEST_MISMATCH", { expectedSha256: sha256("other") });
});
it("fails a payload whose contents no longer match their digest", async () => {
await rejection(
{
tamper: (files) => {
files.set("dist/server/index.js", Buffer.from("export default {}; // attacker-chosen\n"));
},
},
"MODULE_PACKAGE_FILE_MISMATCH",
);
});
it("fails a listed file missing from the archive", async () => {
await rejection(
{ tamper: (files) => void files.delete("THIRD_PARTY_NOTICES") },
"MODULE_PACKAGE_FILE_MISSING",
);
});
it("fails an archive file that is not listed", async () => {
await rejection(
withEntry({ path: "example/attacker-chosen.js", data: "extra" }),
"MODULE_PACKAGE_FILE_UNLISTED",
);
});
it.each([
["a missing license declaration", undefined],
["an empty license declaration", ""],
["UNLICENSED", "UNLICENSED"],
["a malformed SPDX expression", "MIT OR"],
])("fails package metadata with %s", async (_label, license) => {
await rejection(
{
metadata: (metadata) => {
const changed: Record<string, unknown> = { ...metadata, license };
if (license === undefined) {
delete changed.license;
}
return changed;
},
},
"MODULE_PACKAGE_METADATA_INVALID",
);
});
it("fails closed on pre-ADR-0051 metadata", async () => {
await rejection(
{
metadata: (metadata) => {
const old: Record<string, unknown> = { ...metadata };
delete old.license;
return old;
},
},
"MODULE_PACKAGE_METADATA_INVALID",
);
});
it("requires LICENSE at the canonical path in both payload and inventory", async () => {
await rejection(
{ tamper: (files) => void files.delete("LICENSE") },
"MODULE_PACKAGE_FILE_MISSING",
);
await rejection(
{
metadata: (metadata) => ({
...metadata,
files: (metadata.files as { path: string }[]).filter((file) => file.path !== "LICENSE"),
}),
},
"MODULE_PACKAGE_METADATA_INVALID",
);
await rejection(
withEntry({ path: "example/licenses/LICENSE", data: "attacker-chosen" }),
"MODULE_PACKAGE_FILE_UNLISTED",
);
await rejection(
withEntry({ path: "example/COPYING", data: "attacker-chosen" }),
"MODULE_PACKAGE_FILE_UNLISTED",
);
});
it("checks the listed LICENSE bytes, size, and digest", async () => {
await rejection(
{ tamper: (files) => files.set("LICENSE", Buffer.from("attacker-chosen")) },
"MODULE_PACKAGE_FILE_MISMATCH",
);
await rejection(
{
metadata: (metadata) => ({
...metadata,
files: (metadata.files as { path: string; size: number; sha256: string }[]).map((file) =>
file.path === "LICENSE" ? { ...file, size: file.size + 1 } : file,
),
}),
},
"MODULE_PACKAGE_FILE_MISMATCH",
);
});
it("rejects a duplicate LICENSE archive entry", async () => {
await rejection(
withEntry({ path: "example/LICENSE", data: "attacker-chosen" }),
"MODULE_PACKAGE_LAYOUT_INVALID",
);
});
it("fails when the manifest and the package disagree about the version", async () => {
await rejection(
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
"MODULE_PACKAGE_IDENTITY_MISMATCH",
);
});
it("fails when the metadata names another module", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, module: { id: "other", version: "0.1.0" } }) },
"MODULE_PACKAGE_IDENTITY_MISMATCH",
);
});
it("fails a runtime package.json that is not the generated shape", async () => {
await rejection(
{
runtimePackage: {
name: "@test/module-example",
version: "0.1.0",
private: true,
type: "module",
ekklesia: { layer: "module" },
dependencies: { "@ekklesia/module-sdk": "workspace:*" },
},
},
"MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID",
);
});
it("fails an unsupported package format", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, packageFormat: 2 }) },
"MODULE_PACKAGE_FORMAT_UNSUPPORTED",
);
});
it("fails transitional metadata it does not understand", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, transitional: { orm: { prisma: "6.0.0" } } }) },
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
);
await rejection(
{
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2", prisma: "6.0.0" } },
}),
},
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
);
});
it("enforces today's database runtime agreement, and accepts it when it holds", async () => {
await rejection(
{
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.44.0" } },
}),
},
"MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE",
);
const { archivePath } = await archiveFile({
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
}),
});
await expect(verifyModulePackage({ archivePath, platform })).resolves.toMatchObject({
moduleId: "example",
});
});
it("verifies ABI-v1 plain schema metadata without comparing Drizzle with Core", async () => {
const { archivePath } = await archiveFile(abiPackage());
await expect(
verifyModulePackage({
archivePath,
platform: { ...platform, databaseRuntime: { "drizzle-orm": "99.0.0" } },
}),
).resolves.toMatchObject({ moduleId: "example" });
});
it("rejects transitional database runtime metadata from an ABI-v1 package", async () => {
await rejection(
abiPackage({
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
}),
}),
"MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED",
);
});
it("rejects an unsupported database ABI and malformed ABI-v1 schema metadata", async () => {
await rejection(
abiPackage({ manifest: (manifest) => ({ ...manifest, database: { abi: 2 } }) }),
"MODULE_PACKAGE_MANIFEST_INVALID",
);
const schemaError = await rejection(
abiPackage({
schemaSource:
'export default { moduleId: "example", tables: { records: "attacker-chosen" } };\n',
}),
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
);
expect(schemaError.message).not.toContain("attacker-chosen");
});
it("fails a target this platform cannot run", async () => {
await rejection(
{
metadata: (metadata) => ({
...metadata,
target: "linux-arm64-musl",
runtime: { libcVersion: ">=1.2.0", node: ">=20", nodeApi: 9 },
}),
},
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
);
});
it("fails a native file inside an any-target package", async () => {
const native = await modulePackageFixture({
tamper: (files) => {
files.set("dist/addon.node", Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
},
metadata: (metadata) => {
const files = [
...(metadata.files as { path: string; size: number; sha256: string }[]),
{
path: "dist/addon.node",
size: 4,
sha256: sha256(Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
},
].sort((left, right) => (left.path < right.path ? -1 : 1));
return { ...metadata, files };
},
});
const archivePath = path.join(await scratch(), "native.tar.gz");
await writeFile(archivePath, native.archive);
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
code: "MODULE_PACKAGE_NATIVE_FILE",
});
});
it("fails a package that needs a newer Node.js", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, runtime: { node: ">=99" } }) },
"MODULE_PACKAGE_RUNTIME_INCOMPATIBLE",
);
});
it("fails an incorporated SDK outside the manifest's own range", async () => {
await rejection(
{
metadata: (metadata) => ({
...metadata,
incorporates: { "@ekklesia/module-sdk": "0.9.0" },
}),
},
"MODULE_PACKAGE_SDK_INCOMPATIBLE",
);
});
it("fails a module whose declared entrypoint is missing", async () => {
await rejection(
{
manifest: (manifest) => ({
...manifest,
version: "0.1.0",
entrypoints: {
...(manifest.entrypoints as Record<string, unknown>),
schema: "./dist/schema/index.js",
},
}),
},
"MODULE_PACKAGE_MODULE_INVALID",
);
});
it("fails a backend that imports a dependency the package does not contain", async () => {
const error = await rejection(
{
serverSource:
'import pad from "ekklesia-dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0", register() { pad(); } };\n',
},
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
);
expect(error.message).not.toContain("ekklesia-dependency-not-in-this-package");
});
it("fails a backend whose default export names another module", async () => {
await rejection(
{ serverSource: 'export default { id: "other", version: "0.1.0" };\n' },
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
);
});
it.each([
["an absolute path", { path: "/etc/attacker-chosen", data: "x" }, "MODULE_PACKAGE_UNSAFE_PATH"],
[
"a .. segment",
{ path: "example/../attacker-chosen", data: "x" },
"MODULE_PACKAGE_UNSAFE_PATH",
],
[
"a symbolic link",
{ path: "example/link", type: "symlink", linkname: "/etc/passwd" },
"MODULE_PACKAGE_LINK",
],
[
"a hard link",
{ path: "example/hard", type: "hardlink", linkname: "example/package.json" },
"MODULE_PACKAGE_LINK",
],
[
"a device node",
{ path: "example/device", type: "character" },
"MODULE_PACKAGE_SPECIAL_ENTRY",
],
["a FIFO", { path: "example/fifo", type: "fifo" }, "MODULE_PACKAGE_SPECIAL_ENTRY"],
[
"a setuid file",
{ path: "example/setuid", data: "x", mode: 0o4755 },
"MODULE_PACKAGE_UNSAFE_MODE",
],
[
"a setgid directory",
{ path: "example/setgid", type: "directory", mode: 0o2755 },
"MODULE_PACKAGE_UNSAFE_MODE",
],
[
"a second top-level directory",
{ path: "attacker-chosen/file", data: "x" },
"MODULE_PACKAGE_LAYOUT_INVALID",
],
[
"a duplicate entry",
{ path: "example/package.json", data: "{}" },
"MODULE_PACKAGE_LAYOUT_INVALID",
],
] as const)("fails %s", async (_label, entry, code) => {
await rejection(withEntry(entry as FixtureEntry), code);
});
it("fails a file that is not a gzip-compressed tar archive", async () => {
const archivePath = path.join(await scratch(), "plain.tar.gz");
await writeFile(archivePath, "not an archive");
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
code: "MODULE_PACKAGE_UNREADABLE",
});
});
describe("native targets (ADR 0049)", () => {
/** The first bytes of a 64-bit little-endian ELF object for `machine` (62 is x86-64). The
* verifier reads only the identification and machine fields. */
function elfHeader(machine = 62): Buffer {
const header = Buffer.alloc(64);
Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(header);
header.writeUInt16LE(3, 16);
header.writeUInt16LE(machine, 18);
return header;
}
const linuxHost = {
arch: "x64",
libc: { family: "glibc", version: "2.41" },
nodeApi: 10,
nodeAbi: "137",
platform: "linux",
} as const;
function nativePackage(
overrides: {
readonly files?: Record<string, Uint8Array | string>;
readonly target?: string;
readonly runtime?: Record<string, unknown>;
} = {},
): PackageFixtureOptions {
return {
files: overrides.files ?? {
"node_modules/native-thing/index.js": "module.exports = {};\n",
"node_modules/native-thing/lib/native-thing.node": elfHeader(),
"node_modules/native-thing/package.json": '{"name":"native-thing","version":"1.0.0"}\n',
},
metadata: (metadata) => ({
...metadata,
runtime: overrides.runtime ?? {
libcVersion: ">=2.28.0",
node: ">=20",
nodeApi: 9,
},
target: overrides.target ?? "linux-x64-glibc",
}),
};
}
async function verifyOn(
options: PackageFixtureOptions,
host: ModulePackageHost,
): Promise<unknown> {
const { archivePath } = await archiveFile(options);
return verifyModulePackage({ archivePath, platform: { ...platform, host } });
}
async function refusedOn(
options: PackageFixtureOptions,
host: ModulePackageHost,
code: ModulePackageVerificationErrorCode,
): Promise<void> {
await expect(verifyOn(options, host)).rejects.toMatchObject({ code });
}
it("accepts linux-x64-glibc on a matching host, native node_modules and addon included", async () => {
await expect(verifyOn(nativePackage(), linuxHost)).resolves.toMatchObject({
moduleId: "example",
target: "linux-x64-glibc",
});
});
it("refuses another operating system, CPU, or C library family", async () => {
await refusedOn(
nativePackage(),
{ ...linuxHost, platform: "darwin" },
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
);
await refusedOn(
nativePackage(),
{ ...linuxHost, arch: "arm64" },
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
);
// musl reports no glibc version, so the host cannot be proven glibc.
const { libc: _libc, ...musl } = linuxHost;
await refusedOn(nativePackage(), musl, "MODULE_PACKAGE_TARGET_INCOMPATIBLE");
});
it("refuses a target this platform does not implement", async () => {
await refusedOn(
nativePackage({ target: "linux-arm64-glibc" }),
{ ...linuxHost, arch: "arm64" },
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
);
});
it("refuses a C library older than the package needs", async () => {
await refusedOn(
nativePackage(),
{ ...linuxHost, libc: { family: "glibc", version: "2.17" } },
"MODULE_PACKAGE_LIBC_INCOMPATIBLE",
);
await expect(
verifyOn(nativePackage(), { ...linuxHost, libc: { family: "glibc", version: "2.28" } }),
).resolves.toMatchObject({ target: "linux-x64-glibc" });
});
it("refuses a Node-API version or Node.js ABI the host does not provide", async () => {
await refusedOn(
nativePackage(),
{ ...linuxHost, nodeApi: 8 },
"MODULE_PACKAGE_NODE_API_INCOMPATIBLE",
);
const { nodeApi: _nodeApi, ...unknownApi } = linuxHost;
await refusedOn(nativePackage(), unknownApi, "MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
await refusedOn(
nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20", nodeAbi: "115" } }),
linuxHost,
"MODULE_PACKAGE_NODE_API_INCOMPATIBLE",
);
});
it("refuses a native file built for another machine, or an addon that is not one", async () => {
await refusedOn(
nativePackage({
files: { "node_modules/native-thing/lib/native-thing.node": elfHeader(183) },
}),
linuxHost,
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
);
await refusedOn(
nativePackage({
files: { "node_modules/native-thing/lib/libthing.so": elfHeader(183) },
}),
linuxHost,
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
);
await refusedOn(
nativePackage({
files: { "node_modules/native-thing/lib/native-thing.node": "not an ELF object" },
}),
linuxHost,
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
);
});
it("requires a native package to state its Node.js interface and, on Linux, its C library", async () => {
await rejection(
nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20" } }),
"MODULE_PACKAGE_METADATA_INVALID",
);
await rejection(
nativePackage({ runtime: { node: ">=20", nodeApi: 9 } }),
"MODULE_PACKAGE_METADATA_INVALID",
);
});
it("keeps any-target packages free of node_modules, addons, and ELF objects", async () => {
await rejection(
{ files: { "node_modules/thing/index.js": "module.exports = {};\n" } },
"MODULE_PACKAGE_NATIVE_FILE",
);
await rejection({ files: { "dist/thing.node": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE");
await rejection({ files: { "dist/libthing.so": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE");
});
it("establishes this host's facts from Node.js alone", () => {
const host = currentModulePackageHost();
expect(host.platform).toBe(process.platform);
expect(host.arch).toBe(process.arch);
expect(host.nodeApi).toBe(Number(process.versions.napi));
if (process.platform === "linux") {
const report = process.report.getReport() as { header?: { glibcVersionRuntime?: string } };
expect(host.libc?.version).toBe(report.header?.glibcVersionRuntime);
}
});
});
it("fails an archive without package metadata", async () => {
const fixture = await modulePackageFixture();
const { gunzipSync, gzipSync } = await import("node:zlib");
// Rebuild without the metadata entry by filtering the fixture through a fresh archive.
const archivePath = path.join(await scratch(), "no-metadata.tar.gz");
const raw = gunzipSync(fixture.archive);
const blocks: Buffer[] = [];
for (let offset = 0; offset + 512 <= raw.length; ) {
const headerBlock = raw.subarray(offset, offset + 512);
if (headerBlock.every((byte) => byte === 0)) {
break;
}
const name = headerBlock.subarray(0, 100).toString("latin1").replace(/\0.*$/su, "");
const size = Number.parseInt(headerBlock.subarray(124, 136).toString("latin1"), 8);
const length = 512 + Math.ceil(size / 512) * 512;
if (!name.endsWith("ekklesia.package.json")) {
blocks.push(raw.subarray(offset, offset + length));
}
offset += length;
}
await writeFile(archivePath, gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])));
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
code: "MODULE_PACKAGE_METADATA_MISSING",
});
});
});
+1 -1
View File
@@ -28,7 +28,7 @@ interface PermissionInput {
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
+4 -7
View File
@@ -1,11 +1,8 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type {
ModuleDatabase,
ModuleRealtimePublisher,
ModuleRuntimeServices,
} from "@ekklesia/contracts";
import type { ModuleRealtimePublisher } from "@ekklesia/contracts";
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
import { afterEach, describe, expect, it } from "vitest";
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
@@ -22,7 +19,7 @@ afterEach(async () => {
);
});
function fakeServices(): ModuleRuntimeServices {
function fakeServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
}
@@ -31,7 +28,7 @@ function manifest(
permissions: readonly Record<string, string>[] = [],
): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
+98 -26
View File
@@ -1,21 +1,23 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { ModuleConfiguration, ModuleDatabaseExecutor } from "@ekklesia/contracts";
import type {
ModuleConfiguration,
AnyModuleRuntimeServices,
LegacyModuleRuntimeServices,
ModuleDatabase,
ModuleRuntimeServices,
} from "@ekklesia/contracts";
} from "../src/modules/database.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { discoverModules } from "../src/modules/discovery.js";
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
import {
ModuleDiscoveryError,
ModuleRuntimeError,
type ModuleRuntimeIssueCode,
type ModuleRuntimeServicesLookup,
startDiscoveredModuleRuntime,
startModuleRuntime,
} from "../src/modules/index.js";
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
vi.mock("../src/modules/discovery.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
@@ -52,12 +54,13 @@ function manifestFor(
id: string,
options: {
capabilities?: Array<{ id: string; version: string }>;
database?: { abi: 1 };
dependencies?: Record<string, unknown>;
permissions?: Array<{ id: string; baseline: string; description: string }>;
} = {},
): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -67,6 +70,7 @@ function manifestFor(
},
capabilities: options.capabilities ?? [],
permissions: options.permissions ?? [],
...(options.database ? { database: options.database } : {}),
...(options.dependencies ? { dependencies: options.dependencies } : {}),
entrypoints: {
server: "./dist/server/index.js",
@@ -145,13 +149,29 @@ function fakeModuleDatabase(): ModuleDatabase {
}) as unknown as ModuleDatabase;
}
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
return Object.freeze({ database: fakeModuleDatabase() });
function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
const services = new Map<string, LegacyModuleRuntimeServices>();
const database = fakeModuleDatabase();
return new Proxy(services, {
get(target, property, receiver) {
if (property === "get") {
return (moduleId: string) => {
let value = target.get(moduleId);
if (!value) {
value = Object.freeze({ database });
target.set(moduleId, value);
}
return value;
};
}
return Reflect.get(target, property, receiver);
},
});
}
function runtimeOptions(
modulesDirectory: string,
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
services: ModuleRuntimeServicesLookup = fakeModuleRuntimeServices(),
) {
return {
moduleSdkVersion: "0.1.0",
@@ -1067,6 +1087,54 @@ describe("already-discovered module runtime", () => {
});
describe("module runtime services", () => {
it("delivers only the manifest-selected database mode and distinct ABI executors", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createReferenceRecorder();
const legacyDatabase = fakeModuleDatabase();
const alphaExecutor = Object.freeze({
abiVersion: 1,
query: vi.fn(),
transaction: vi.fn(),
}) as unknown as ModuleDatabaseExecutor;
const betaExecutor = Object.freeze({
abiVersion: 1,
query: vi.fn(),
transaction: vi.fn(),
}) as unknown as ModuleDatabaseExecutor;
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>([
["legacy", Object.freeze({ database: legacyDatabase })],
["alpha", Object.freeze({ database: alphaExecutor })],
["beta", Object.freeze({ database: betaExecutor })],
]);
await createRuntimeModule(modulesDirectory, "legacy", {
serverSource: `export default { id: "legacy", version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
});
for (const id of ["alpha", "beta"]) {
await createRuntimeModule(modulesDirectory, id, {
manifest: manifestFor(id, { database: { abi: 1 } }),
serverSource: `export default { database: { abi: 1 }, id: ${JSON.stringify(id)}, version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
});
}
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
expect(recorder.values).toEqual([alphaExecutor, betaExecutor, legacyDatabase]);
expect("select" in alphaExecutor).toBe(false);
expect(alphaExecutor).not.toBe(betaExecutor);
await runtime.stop();
});
it("rejects a runtime database declaration that disagrees with its manifest", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "mismatch", {
manifest: manifestFor("mismatch", { database: { abi: 1 } }),
serverSource: `export default { id: "mismatch", version: "0.1.0" };`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toContain("MODULE_RUNTIME_DATABASE_MISMATCH");
});
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
@@ -1086,11 +1154,11 @@ describe("module runtime services", () => {
expect(registerServices.values).toEqual([undefined]);
expect(lifecycleServices.values).toHaveLength(2);
expect(lifecycleServices.values[0]).toBe(services);
expect(lifecycleServices.values[1]).toBe(services);
expect(lifecycleServices.values[0]).toBe(services.get("wired"));
expect(lifecycleServices.values[1]).toBe(services.get("wired"));
});
it("shares the exact same services object identity across every module's start hook", async () => {
it("selects a stable services object per module rather than sharing one across modules", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder();
@@ -1112,7 +1180,8 @@ describe("module runtime services", () => {
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
expect(recorder.values).toEqual([services, services]);
expect(recorder.values).toEqual([services.get("alpha"), services.get("beta")]);
expect(recorder.values[0]).not.toBe(recorder.values[1]);
await runtime.stop();
});
@@ -1141,23 +1210,26 @@ describe("module runtime services", () => {
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
).rejects.toBeInstanceOf(ModuleRuntimeError);
expect(recorder.values).toEqual([services, services]);
expect(recorder.values).toEqual([services.get("started"), services.get("started")]);
});
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const database = fakeModuleDatabase();
const services: ModuleRuntimeServicesLookup = new Map([
["unused", Object.freeze({ database })],
]);
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
await runtime.stop();
expect(runtime.modules).toEqual([]);
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
expect(vi.mocked(database.select)).not.toHaveBeenCalled();
expect(vi.mocked(database.insert)).not.toHaveBeenCalled();
expect(vi.mocked(database.update)).not.toHaveBeenCalled();
expect(vi.mocked(database.delete)).not.toHaveBeenCalled();
expect(vi.mocked(database.execute)).not.toHaveBeenCalled();
expect(vi.mocked(database.transaction)).not.toHaveBeenCalled();
});
});
@@ -1463,7 +1535,7 @@ describe("module configuration delivery (ADR 0029)", () => {
expect(recorder.values).toEqual([["object", []]]);
});
it("keeps the services object one shared identity even though configuration is per-module", async () => {
it("keeps each module's services object stable while configuration remains per-module", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder();
@@ -1492,11 +1564,11 @@ describe("module configuration delivery (ADR 0029)", () => {
await runtime.stop();
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
// This is the reason configuration is a sibling of `services` rather than a field inside it:
// `services` is deliberately one shared object for the whole runtime, while configuration must
// differ per module. Both properties hold simultaneously.
expect(alphaServices).toBe(services);
expect(bravoServices).toBe(services);
// Configuration remains a sibling of `services`: both are selected per module, but they are
// independently composed snapshots with separate contracts and lifetimes.
expect(alphaServices).toBe(services.get("alpha"));
expect(bravoServices).toBe(services.get("bravo"));
expect(alphaServices).not.toBe(bravoServices);
expect(alphaConfig).not.toBe(bravoConfig);
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
+63 -2
View File
@@ -38,10 +38,14 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(
id: string,
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
options: {
database?: { abi: 1 };
dependencies?: Record<string, unknown>;
schema?: string | false;
} = {},
): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -49,6 +53,7 @@ function manifestFor(
...(options.dependencies ? { dependencies: options.dependencies } : {}),
capabilities: [],
permissions: [],
...(options.database ? { database: options.database } : {}),
entrypoints: {
server: "./dist/server/index.js",
...(options.schema === false ? {} : { schema: options.schema ?? "./dist/schema/index.js" }),
@@ -126,6 +131,62 @@ function issueCodes(error: ModuleSchemaDiscoveryError): ModuleSchemaDiscoveryIss
}
describe("module schema discovery", () => {
it("loads ABI-v1 schema entrypoints as plain metadata without importing an ORM", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "calendar", {
manifest: manifestFor("calendar", { database: { abi: 1 } }),
schemaSource: `
export default {
moduleId: "calendar",
tables: [
{ name: "calendar_events", foreignKeys: [] },
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] }
]
};
`,
});
const aggregate = await discoverModuleSchemas(discoveryOptions(modulesDirectory));
expect(aggregate.tables.map((table) => table.name)).toEqual([
"calendar_attendees",
"calendar_events",
]);
expect(aggregate.tables.every((table) => table.table === undefined)).toBe(true);
expect(aggregate.drizzleSchema).toEqual({});
});
it("aggregates ABI-v1 prefix, duplicate, cross-module FK, missing target, and malformed errors", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "alpha", {
manifest: manifestFor("alpha", { database: { abi: 1 } }),
schemaSource: `export default {
moduleId: "alpha",
tables: [
{ name: "wrong_records", foreignKeys: [] },
{ name: "alpha_records", foreignKeys: [{ table: "beta_records" }] },
{ name: "alpha_records", foreignKeys: [{ table: "alpha_missing" }] }
]
};`,
});
await createSchemaModule(modulesDirectory, "malformed", {
manifest: manifestFor("malformed", { database: { abi: 1 } }),
schemaSource: `export default { moduleId: "malformed", tables: [{ name: "malformed_rows", unknown: true }] };`,
});
const error = await captureSchemaError(modulesDirectory);
expect(issueCodes(error)).toEqual(
expect.arrayContaining([
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
"MODULE_SCHEMA_EXPORT_INVALID",
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
"MODULE_SCHEMA_TABLE_DUPLICATE",
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
]),
);
});
it("returns a frozen empty aggregate without importing module servers", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createGlobalEventRecorder();
+1 -1
View File
@@ -18,7 +18,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
+12
View File
@@ -1,5 +1,6 @@
import {
EMPTY_MODULE_STORAGE,
isModuleStorageError,
isValidModuleStorageKey,
MODULE_STORAGE_KEY_MAX_BYTES,
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
}
});
it("recognizes a storage error from an installed module's separate package copy", () => {
const external = Object.assign(new Error("The storage service could not be reached."), {
code: "STORAGE_PROVIDER_UNAVAILABLE",
name: "ModuleStorageError",
});
expect(external).not.toBeInstanceOf(ModuleStorageError);
expect(isModuleStorageError(external)).toBe(true);
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
});
it("gives a module no way to reach another module's storage by knowing its name", async () => {
const shared = fakeProvider();
const announcements = createModuleStorage({
+1 -1
View File
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: contribution.version,
})),
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
permissions:
fixture.display === undefined
+1 -1
View File
@@ -1905,7 +1905,7 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const templateModule = path.join(repositoryRoot, "templates", "module");
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
const versions = { moduleSdkVersion: "0.2.0", platformVersion: "0.2.0" } as const;
async function coreWithoutNewestMigration(directory: string): Promise<string> {
const target = path.join(directory, "core-drizzle");
+19
View File
@@ -0,0 +1,19 @@
/**
* The per-test timeout for Core's import-safety tests, and only those.
*
* Each of these tests proves that importing a module does nothing (no I/O, no connection, no output)
* by performing a cold dynamic `import()` of a large Core source graph inside the test body. Vitest
* transforms and evaluates that whole graph on demand, and that work is CPU-bound. An ordinary test
* file's own top-level imports run before its tests start and fall under no per-test timeout; these
* deliberately do not, so the cost lands inside Vitest's default 5-second test timeout.
*
* Alone they take about 0.7–1.2 s. During a full `pnpm verify`, Turbo runs every package's suite at
* once and the CPU is saturated. Reproduced locally (six cores, Core's suite alongside five other
* packages' suites), the same tests took 3.0–5.4 s, and so timed out intermittently.
*
* 30 seconds is about five times the slowest time measured under load, which leaves room for slower
* shared CI runners. It matches the repository's integration-test timeout. It is still bounded, so a
* module that genuinely hangs while being imported, for example on a network call, still fails. The
* assertions themselves are unchanged: these tests fail on what an import does, never on speed.
*/
export const IMPORT_SAFETY_TEST_TIMEOUT_MS = 30_000;
@@ -50,11 +50,11 @@ export function manifestFor(
} = {},
): Record<string, unknown> {
return {
manifestVersion: 7,
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" },
...(options.dependencies ? { dependencies: options.dependencies } : {}),
capabilities: [],
permissions: [],
@@ -0,0 +1,214 @@
import { createHash } from "node:crypto";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { gzipSync } from "node:zlib";
import { createMigrationModule } from "./migration-module-fixture.js";
/**
* Test-only builders for Ekklesia Module Package v1 archives (ADR 0049). They write ustar archives
* by hand so a test can produce exactly what the canonical packager never would: links, device
* nodes, traversal, a second top-level directory, a corrupted payload.
*/
export type FixtureEntryType = "file" | "directory" | "symlink" | "hardlink" | "character" | "fifo";
export interface FixtureEntry {
readonly path: string;
readonly type?: FixtureEntryType;
readonly mode?: number;
readonly data?: Uint8Array | string;
readonly linkname?: string;
}
const typeflags: Readonly<Record<FixtureEntryType, string>> = {
character: "3",
directory: "5",
fifo: "6",
file: "0",
hardlink: "1",
symlink: "2",
};
function octal(value: number, width: number): string {
return `${value.toString(8).padStart(width - 1, "0")}\u0000`;
}
function header(entry: FixtureEntry, size: number): Buffer {
const block = Buffer.alloc(512);
const name =
entry.type === "directory" && !entry.path.endsWith("/") ? `${entry.path}/` : entry.path;
let prefix = "";
let base = name;
if (Buffer.byteLength(name) > 100) {
const split = name.lastIndexOf("/", name.length - 2);
prefix = name.slice(0, split);
base = name.slice(split + 1);
}
block.write(base, 0, 100, "latin1");
block.write(octal(entry.mode ?? (entry.type === "directory" ? 0o755 : 0o644), 8), 100, "latin1");
block.write(octal(0, 8), 108, "latin1");
block.write(octal(0, 8), 116, "latin1");
block.write(octal(size, 12), 124, "latin1");
block.write(octal(0, 12), 136, "latin1");
block.write(" ", 148, "latin1");
block.write(typeflags[entry.type ?? "file"], 156, "latin1");
block.write(entry.linkname ?? "", 157, 100, "latin1");
block.write("ustar\u000000", 257, "latin1");
block.write(prefix, 345, 155, "latin1");
let checksum = 0;
for (const byte of block) {
checksum += byte;
}
block.write(`${checksum.toString(8).padStart(6, "0")}\u0000 `, 148, "latin1");
return block;
}
/** A gzip-compressed ustar archive of exactly these entries, in this order. */
export function tarGz(entries: readonly FixtureEntry[]): Buffer {
const parts: Buffer[] = [];
for (const entry of entries) {
const data =
entry.type === undefined || entry.type === "file"
? Buffer.from(entry.data ?? "")
: Buffer.alloc(0);
parts.push(header(entry, data.length), data, Buffer.alloc((512 - (data.length % 512)) % 512));
}
parts.push(Buffer.alloc(1024));
return gzipSync(Buffer.concat(parts), { level: 9 });
}
export function sha256(data: Uint8Array | string): string {
return createHash("sha256").update(data).digest("hex");
}
export interface PackageFixtureOptions {
readonly id?: string;
readonly version?: string;
readonly serverSource?: string;
readonly schemaSource?: string;
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
readonly runtimePackage?: Record<string, unknown>;
readonly metadata?: (metadata: Record<string, unknown>) => Record<string, unknown>;
/** Changes the payload after its digests were computed. */
readonly tamper?: (files: Map<string, Buffer>) => void;
/** Entries appended after the package's own, under any path. */
readonly extraEntries?: readonly FixtureEntry[];
/** Extra payload files, listed in the metadata like every other file. */
readonly files?: Readonly<Record<string, Uint8Array | string>>;
}
export interface PackageFixture {
readonly archive: Buffer;
readonly sha256: string;
}
async function readTree(root: string): Promise<Map<string, Buffer>> {
const files = new Map<string, Buffer>();
for (const entry of await readdir(root, { recursive: true, withFileTypes: true })) {
if (entry.isFile()) {
const absolute = path.join(entry.parentPath, entry.name);
files.set(path.relative(root, absolute).split(path.sep).join("/"), await readFile(absolute));
}
}
return files;
}
/**
* A valid any-target package of a minimal module: a server stub with no imports, one migration, a
* notices file, the generated runtime package.json, and complete metadata. Every option changes one
* thing, so a test can break exactly the rule it is about.
*/
export async function modulePackageFixture(
options: PackageFixtureOptions = {},
): Promise<PackageFixture> {
const id = options.id ?? "example";
const version = options.version ?? "0.1.0";
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-fixture-"));
let files: Map<string, Buffer>;
try {
const created = await createMigrationModule(scratch, id, {
...(options.schemaSource === undefined ? {} : { schemaSource: options.schemaSource }),
serverSource:
options.serverSource ??
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`,
});
files = await readTree(created.moduleDirectory);
} finally {
await rm(scratch, { force: true, recursive: true });
}
const manifest = JSON.parse(
files.get("ekklesia.module.json")?.toString("utf8") ?? "{}",
) as Record<string, unknown>;
files.set(
"ekklesia.module.json",
Buffer.from(
`${JSON.stringify(options.manifest ? options.manifest(manifest) : { ...manifest, version }, null, 2)}\n`,
),
);
files.set(
"package.json",
Buffer.from(
`${JSON.stringify(
options.runtimePackage ?? {
name: `@test/module-${id}`,
version,
private: true,
type: "module",
ekklesia: { layer: "module" },
},
null,
2,
)}\n`,
),
);
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
files.set("LICENSE", Buffer.from("Example module license text.\n"));
for (const [filePath, data] of Object.entries(options.files ?? {})) {
files.set(filePath, Buffer.from(data));
}
const listed = [...files.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([filePath, data]) => ({ path: filePath, size: data.length, sha256: sha256(data) }));
const baseMetadata: Record<string, unknown> = {
packageFormat: 1,
module: { id, version },
license: "MIT",
target: "any",
runtime: { node: ">=20" },
provenance: {
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
packager: { name: "@ekklesia/module-pack", version: "0.2.0" },
},
incorporates: { "@ekklesia/module-sdk": "0.2.0" },
files: listed,
};
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
options.tamper?.(files);
const directories = new Set<string>([id]);
for (const filePath of files.keys()) {
const segments = filePath.split("/");
for (let index = 1; index < segments.length; index += 1) {
directories.add(`${id}/${segments.slice(0, index).join("/")}`);
}
}
const all = new Map<string, FixtureEntry>();
for (const directory of directories) {
all.set(directory, { path: directory, type: "directory" });
}
for (const [filePath, data] of files) {
all.set(`${id}/${filePath}`, { path: `${id}/${filePath}`, data });
}
all.set(`${id}/ekklesia.package.json`, {
path: `${id}/ekklesia.package.json`,
data: `${JSON.stringify(metadata, null, 2)}\n`,
});
const ordered = [...all.values()].sort((left, right) =>
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
);
const archive = tarGz([...ordered, ...(options.extraEntries ?? [])]);
return { archive, sha256: sha256(archive) };
}
@@ -0,0 +1,92 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
MODULE_VERIFIER_SCRATCH_ENV,
moduleVerifierScratchDirectory,
parseSha256File,
parseVerifyModulePackageArguments,
readModulePackagePlatform,
} from "../src/cli/verify-module-package.js";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const digest = "ab".repeat(32);
describe("verify-module-package arguments", () => {
it("takes an archive and, optionally, a digest and an extraction directory", () => {
expect(parseVerifyModulePackageArguments(["example-0.1.0-any.tar.gz"])).toEqual({
archivePath: "example-0.1.0-any.tar.gz",
});
expect(
parseVerifyModulePackageArguments([
"x.tar.gz",
"--sha256",
digest.toUpperCase(),
"--extract-to",
"/staging/example",
]),
).toEqual({ archivePath: "x.tar.gz", expectedSha256: digest, extractTo: "/staging/example" });
expect(
parseVerifyModulePackageArguments(["x.tar.gz", "--sha256-file", "x.tar.gz.sha256"]),
).toEqual({ archivePath: "x.tar.gz", sha256File: "x.tar.gz.sha256" });
});
it.each([
["no archive", []],
["two archives", ["a.tar.gz", "b.tar.gz"]],
["an unknown option", ["a.tar.gz", "--force"]],
["both digest options", ["a.tar.gz", "--sha256", digest, "--sha256-file", "a.sha256"]],
["a malformed digest", ["a.tar.gz", "--sha256", "abc"]],
["an option without its value", ["a.tar.gz", "--extract-to"]],
])("refuses %s", (_label, argv) => {
expect(() => parseVerifyModulePackageArguments(argv)).toThrow();
});
});
describe("verify-module-package digest files", () => {
it("reads the sha256sum format, ignoring the recorded file name", () => {
expect(parseSha256File(`${digest} example-0.1.0-any.tar.gz\n`)).toBe(digest);
expect(parseSha256File(`${digest.toUpperCase()} *renamed.tar.gz\n`)).toBe(digest);
});
it.each([
"",
"not-a-digest x.tar.gz\n",
`${digest}\n${digest} second\n`,
])("refuses %j", (contents) => {
expect(() => parseSha256File(contents)).toThrow();
});
});
describe("verify-module-package execution scratch", () => {
it("accepts an absolute operator-configured directory and otherwise leaves the library default", () => {
expect(moduleVerifierScratchDirectory({})).toBeUndefined();
expect(
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: "/run/ekklesia-verifier" }),
).toBe("/run/ekklesia-verifier");
});
it.each(["", "relative/path"])("refuses the invalid configured path %j", (configured) => {
expect(() =>
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: configured }),
).toThrow(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
});
});
describe("verify-module-package platform facts", () => {
it("reads this platform's versions and its transitional database runtime from the repository", async () => {
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
const core = JSON.parse(
await readFile(path.join(repositoryRoot, "core", "package.json"), "utf8"),
);
const sdk = JSON.parse(
await readFile(path.join(repositoryRoot, "packages", "module-sdk", "package.json"), "utf8"),
);
await expect(readModulePackagePlatform(repositoryRoot)).resolves.toEqual({
databaseRuntime: { "drizzle-orm": core.dependencies["drizzle-orm"] },
moduleSdkVersion: sdk.version,
platformVersion: root.version,
});
});
});
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "../packages/typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
"types": ["node", "vitest/globals"],
"rewriteRelativeImportExtensions": true
},
"include": [
"drizzle.config.ts",
+14 -1
View File
@@ -42,9 +42,16 @@
{$EKKLESIA_HOSTNAME} {
# Everything the API serves: the Dashboard at /, the API, display pages and their service
# worker, and the Dashboard and display host scripts. Any other path is refused here.
# worker, and the Dashboard and display host scripts.
@ekklesia path / /api/* /display/* /assets/*
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
# Any other path is refused here.
@dashboardPage path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
# Module realtime is Server-Sent Events: flush every event immediately.
@realtime path /api/v1/module-realtime/*
@@ -63,6 +70,12 @@
}
}
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle {
respond 404
}
+16
View File
@@ -51,12 +51,28 @@
{$EKKLESIA_DASHBOARD_HOSTNAME} {
@dashboard path / /assets/ekklesia-dashboard/*
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
# The API's own namespaces stay refused on this hostname.
@dashboardPage {
path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
not path /api /api/* /display /display/* /assets /assets/*
}
handle @dashboard {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle {
respond 404
}
+62 -17
View File
@@ -17,6 +17,7 @@ specific to one organization.
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
| [`display-kiosk.md`](display-kiosk.md) | Setting up an unattended display screen: launch, pairing, and recovery. |
## The reference deployment
@@ -142,12 +143,16 @@ Installed modules live on the host, in module storage: `/var/lib/ekklesia/module
and `migrate`, which is where `EKKLESIA_MODULES_DIR` points. Ekklesia never writes there.
Installing, updating, or removing a module never needs a new image, only a restart.
**What belongs there.** One directory per module, each a self-contained installed module: its
`package.json`, `ekklesia.module.json`, built artifacts, migrations, and the production
dependencies its server code imports, in its own `node_modules`
**What belongs there.** One directory per module, each extracted from that module's **Ekklesia
Module Package**
([ADR 0049](../docs/architecture/decisions/0049-module-package-v1.md)): one archive,
`<id>-<version>-<target>.tar.gz`, and the SHA-256 published beside it. A package holds the module's
generated `package.json`, its manifest, `ekklesia.package.json`, bundled backend code, built browser
artifacts, migrations, and `THIRD_PARTY_NOTICES`
([Module development](../docs/module-development/README.md#an-installed-module-is-self-contained)).
Copying a module's `dist/` alone is not enough. Files are owned by root and readable by everyone
(directories 755, files 644), so the API's unprivileged user can read them and never change them.
The digest proves the archive is the one you were given; it does not prove who published it.
**Nothing else belongs there.** Every non-hidden directory must be a valid module, or the API does
not start. That includes `lost+found`, so if module storage is a file system of its own, mount it
@@ -159,12 +164,39 @@ never loads or unloads a module while it runs. Change module storage only while
it reads browser artifacts from disk as it serves them, so files changed under a running API would
serve one version's browser code beside another's server code.
To **install or update** a module, with `<id>` its module id:
To **install or update** a module, with `<id>` its module id and the package and its `.sha256` in
the current directory, first **verify** it with the image's own verifier. It checks the archive's
digest, its safety (no links, special files, or escaping paths), every file's digest, the package's
identity, target, and Node.js range against this platform, runs the platform's module and migration
validation, and loads the backend once in a short-lived process with no environment. Only then does
it write the verified module directory, to a new directory outside module storage:
The backend probe executes the package's server-side code; the verifier container is its isolation
boundary. Keep the general-purpose `/tmp` mount `noexec`, and give only this one-shot verifier a
separate executable scratch tmpfs. The mount below is ephemeral, bounded at 1100 MiB (just above the
package format's 1 GiB expanded limit), `nodev`, `nosuid`, and writable only by the image's
unprivileged `node` user. `EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR` tells the CLI where that mount is;
the verifier creates a unique child for each run and removes it on success or failure.
```sh
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
docker run --rm --network none --read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
--cap-drop ALL --security-opt no-new-privileges \
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
--extract-to /staging/<id>
```
Then install the verified directory:
```sh
docker compose stop api
# Stage under a hidden name, which discovery ignores, then rename it into place in one step.
sudo cp -R ./<id> /var/lib/ekklesia/modules/.incoming-<id>
sudo cp -R staging/<id> /var/lib/ekklesia/modules/.incoming-<id>
sudo chown -R root:root /var/lib/ekklesia/modules/.incoming-<id>
sudo chmod -R a+rX,go-w /var/lib/ekklesia/modules/.incoming-<id>
sudo mv /var/lib/ekklesia/modules/<id> /var/lib/ekklesia/modules/.previous-<id> # update only
@@ -188,7 +220,8 @@ is information, not a problem, and it blocks no other migration. To stop a modul
it, disable it instead (`set-module-enabled.js --disable`, [Operator commands](#operator-commands)).
There is no purge: deleting a module's data is a separate operation that does not exist yet.
To **restore** a removed module, install it again as above, at the same version or a newer one. It
To **restore** a removed module, verify and install its package again as above, at the same version
or a newer one. It
finds its data, its settings, and its history where it left them. Its history is strict again: a
package that no longer contains a migration that was applied, or contains one with different
contents, is refused by the migration plan, so an older version cannot be installed over newer data.
@@ -208,6 +241,14 @@ Module storage is installation state: back it up with the database and the bucke
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
**Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with
the new `Caddyfile.example` (or `Caddyfile.split.example`). Each gains a `@dashboardPage` matcher
and `handle` block that forward every top-level path shaped like a module id — `/settings`,
`/account`, and each installed module's page such as `/signage` — to the API, which answers with
the Dashboard page where one exists and its sanitized `404` elsewhere. Without it the Dashboard still
opens at `/`, but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the
proxy after editing it. Installing or removing a module needs no proxy change.
### Rolling back
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
@@ -230,7 +271,7 @@ The same image serves both; only configuration differs.
| Hostname | Serves | Refuses (`404`) |
| --- | --- | --- |
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, `/assets/ekklesia-dashboard/*`, and every module-shaped top-level path (the Dashboard's pages, such as `/settings` and `/signage`; the API answers the rest with its `404`) | everything else, including `/api/*`, `/display/*`, and `/assets/*` |
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
@@ -306,7 +347,11 @@ networks:
Then make your proxy do what `Caddyfile.example` does:
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`,
and every path whose first segment is shaped like a module id, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$` (ADR 0057): that is
`/settings`, `/account`, and each installed module's page such as `/signage`, and the API answers
anything that is not a real page with its sanitized `404`. Without it, refreshing any Dashboard page
other than `/` is your proxy's `404`. Do not list module ids: they change as modules are installed;
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
@@ -372,7 +417,7 @@ deployment:
| # | Item | Where it is met |
| --- | --- | --- |
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*`, and every module-shaped top-level path (the Dashboard's pages) on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
@@ -409,10 +454,9 @@ deployment:
instead, on the **same generic image**, and also proves [Installed modules](#installed-modules)
end to end:
- the image boots with no module;
- the repository's module template, packaged as a self-contained test module
(`scripts/lib/test-module-package.Dockerfile`, test-only and not a package format), is installed
into module storage, its migration is planned and explicitly applied, the API restarts, and the
module reads data it owns;
- the repository's module template, packaged as an Ekklesia Module Package v1 by the canonical
packager, is verified by the image itself and installed into module storage, its migration is
planned and explicitly applied, the API restarts, and the module reads data it owns;
- every hostname's served and refused paths, exact CORS for the Dashboard's origin and none for
others;
- a real headless Chromium (pinned by digest, driven over the DevTools protocol) signs in on the
@@ -421,8 +465,8 @@ deployment:
API's hostname with its service worker, and signs out;
- the module is removed and the API restarted: Core still runs, the module's routes are gone,
migration planning and applying succeed and report its retained history, and its data is intact;
- the module is restored and the API restarted: its history validates strictly and it returns its
previous data.
- the exact same package is verified and installed again and the API restarted: its history
validates strictly and it returns its previous data.
CI runs both topologies on every change.
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run
@@ -480,10 +524,10 @@ finds its files:
```sh
docker build \
--file deploy/Dockerfile \
--build-arg VERSION=0.1.2 \
--build-arg VERSION=0.2.5 \
--build-arg REVISION="$(git rev-parse HEAD)" \
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
--tag ekklesia:0.1.2 \
--tag ekklesia:0.2.5 \
.
```
@@ -586,6 +630,7 @@ reports direct mode for an API that trusts its proxy. The running API's own valu
| Doctor (read-only report) | `docker compose run --rm migrate node core/dist/cli/doctor.js` |
| Create the initial owner (interactive) | `docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email [email protected]` |
| Validate the installed modules (read-only) | `docker compose run --rm migrate node core/dist/cli/validate-modules.js` |
| Verify a module package (read-only unless extracting) | `node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256 [--extract-to <new directory>]`, in a `docker run` of the image ([Installed modules](#installed-modules)) |
| Enable or disable a module | `docker compose run --rm migrate node core/dist/cli/set-module-enabled.js --enable --module <id>` (or `--disable`) |
Every command reads installed modules from the same `EKKLESIA_MODULES_DIR` the API does. The plan,
+4
View File
@@ -132,6 +132,10 @@ services:
command:
- mini
- -dir=/data
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
# volume size leaves modest hosts with room for only a few collections; after that, writes
# stall instead of failing. This changes allocation granularity, not object or total capacity.
- -master.volumeSizeLimitMB=32
- -s3.port.https=8443
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
- -s3.key.file=/run/ekklesia/tls/object-storage.key
+130
View File
@@ -0,0 +1,130 @@
# Setting up a display kiosk
A display is a screen — a television with a small computer, or a mini PC on a wall — that opens one
module's display page full screen and keeps it open unattended. This guide sets one up so it starts
on boot, stays paired, recovers from network and server outages on its own, and never opens a second
copy of itself.
Pairing and display identity belong to the platform (ADR 0036). Today an administrator approves a
screen in the Dashboard sidebar's **Display devices** panel; a later release moves this to
**Settings → Displays**. Nothing below changes when that happens.
## What the screen needs
- Chromium or Google Chrome, current stable. The display page is tested against Chromium.
- A **dedicated, persistent browser profile directory**. The device credential is an HttpOnly cookie
in that profile; it is what keeps the screen paired across reboots. Never use a guest, incognito, or
"clear data on exit" profile, and never share the profile with a person's browsing.
- Network access to the installation's **API hostname** (the display page, its realtime stream, and
its module requests) and to the storage hostname that serves images.
- A clock that is roughly right. Playback follows the server's clock, but TLS does not tolerate a
wildly wrong one.
## The address
Every display-capable module has one display page on the API hostname:
```text
https://<api-hostname>/display/<module-id>
```
For Signage that is `https://<api-hostname>/display/signage`. The Signage **Displays** tab shows the
exact address under **Copy setup address**. Open it on the screen itself; opening it on an
administrator's own computer only previews the display (and that browser appears in the roster as a
*Signed-in browser*, which can be removed once it is offline).
## Launching the browser
Run exactly **one** launcher, and launch the page as an app window with a fixed profile:
```bash
chromium \
--user-data-dir=/var/lib/display-kiosk/profile \
--app=https://<api-hostname>/display/signage \
--kiosk \
--no-first-run \
--noerrdialogs \
--disable-session-crashed-bubble \
--hide-crash-restore-bubble \
--disable-features=Translate \
--autoplay-policy=no-user-gesture-required \
--check-for-update-interval=31536000
```
- `--app` opens the page with no tab strip and no address bar. `--kiosk` makes it full screen.
- `--user-data-dir` is the persistent profile that holds the pairing. Keep it on persistent storage
and back it up with nothing: if it is lost, the screen simply shows a new pairing code.
- Do **not** add `--restore-last-session`, and do not also start the browser from a desktop autostart
entry. Chromium allows one process per profile; a second launch hands its URL to the running
process, which opens a **second window or tab** of the display. That is the usual cause of
"duplicate" displays. One launcher, one profile, one window.
### Start on boot and restart on exit (systemd)
Run the browser as an unprivileged user in that user's graphical session, from a single user unit:
```ini
# ~/.config/systemd/user/display-kiosk.service
[Unit]
Description=Display kiosk
After=graphical-session.target network-online.target
Wants=network-online.target
PartOf=graphical-session.target
[Service]
ExecStart=/usr/bin/chromium --user-data-dir=%h/.local/share/display-kiosk --app=https://<api-hostname>/display/signage --kiosk --no-first-run --noerrdialogs --disable-session-crashed-bubble --hide-crash-restore-bubble --disable-features=Translate --autoplay-policy=no-user-gesture-required --check-for-update-interval=31536000
Restart=always
RestartSec=5
[Install]
WantedBy=graphical-session.target
```
```bash
systemctl --user daemon-reload
systemctl --user enable --now display-kiosk.service
loginctl enable-linger <kiosk-user> # with the desktop set to log this user in automatically
```
`systemctl --user stop display-kiosk` closes the browser cleanly, which is what keeps its profile —
and the pairing cookie — intact. Avoid cutting power to a running kiosk when you can: a clean shutdown
is always safer for a browser profile.
## Pairing
1. Start the kiosk. With no credential it shows a **six-digit pairing code**.
2. In the Dashboard, enter the code under **Display devices**, give the screen a name, and approve.
3. Within a few seconds the screen collects its credential and starts showing content.
4. In Signage → **Displays**, name the display and choose its group and audience.
A paired screen stays paired across browser restarts and reboots, and for as long as it is in use:
its credential is renewed each time it checks in, and only expires after **180 days without being
used** (ADR 0056). A screen that is switched off and stored for longer than that, or that is revoked,
returns to its pairing code and is paired again the same way.
## What happens when things go wrong
| Event | What the screen does |
| --- | --- |
| Network drops for seconds | Nothing visible. The display keeps running; the realtime stream reconnects. |
| Network or API down for longer | Keeps playing what it has cached, with a small corner note; recovers by itself when the server is back. No remount, no reload. |
| API restarted or upgraded | Reconnects by itself. A new module version is picked up within five minutes and swapped in. |
| Browser crashes or is restarted | systemd restarts it; the same profile is still paired. |
| Machine reboots | Starts on boot and resumes. If the server is not up yet, the screen retries on its own (every few seconds, backing off to once a minute) until it is. |
| Display revoked in the Dashboard | Stops showing content within seconds and returns to its pairing code. |
| Display moved to another group or audience | Switches content within about one heartbeat (≈12 s), no reload. |
**Offline cold start is not supported yet.** A screen that is already showing content survives an
outage. A screen that *boots* while the server is completely unreachable can load its cached page but
cannot mount the module until the server answers, because the authorization it needs is never cached
(ADR 0043, Signage ADR 0006). It shows a short message and retries on its own until the server is
reachable — nobody needs to touch it.
## Checklist for a new kiosk
- [ ] One systemd unit, no desktop autostart entry, no second launcher.
- [ ] `--user-data-dir` on persistent storage, not shared, not cleared on exit.
- [ ] Automatic login of the kiosk user, and screen blanking/power saving disabled.
- [ ] Paired and named; assigned in Signage.
- [ ] Unplug the network for a minute: content keeps playing, then the note clears on reconnect.
- [ ] Reboot: the screen comes back paired without anyone touching it.
@@ -267,6 +267,8 @@ to follow — and the API never builds frontend source at run time.
and its only allowed build script, and a second toolchain buys HMR that a proof does not need.
- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus
module routing is a design this pass should not settle by accident.
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
> now has host-owned URL routing; module routing remains a separate, deferred decision.
- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs
to the host, and adopting it here for a two-item selector would prejudge that design.
- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
@@ -255,6 +255,10 @@ that is unremarkable, and it is measured work rather than hidden.
## Deferred
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** Client-side
> routing and deep links to a selected module, deferred below, have landed; the rest of this list is
> unchanged.
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
@@ -165,6 +165,10 @@ redirect target.
A future client router may define an internal return-location contract, but it must be a separately
validated, same-origin route decision. This pass does not create one speculatively.
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
> now has a host-owned router. It adds no return-location contract: sign-in still happens in place at
> the current URL, which is now a real page address.
### Cookie and origin policy does not broaden for the Dashboard
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
@@ -2,6 +2,7 @@
- Status: Accepted
- Date: 2026-08-24
- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md)
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
[0032](0032-native-module-realtime-transport.md),
[0033](0033-display-kiosk-host-contract.md),
@@ -17,6 +17,8 @@
steps 1 and 3 below build the module into a deployment image, which ADR 0048 rules out. Before
this proposal is accepted, the harness must run the module from module storage in the generic
image.
- Should consume: [ADR 0049](0049-module-package-v1.md), when accepted — the harness installs the
module's canonical package, the artifact a church would install, rather than a workspace build.
## Context
@@ -0,0 +1,426 @@
# 0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1
- Status: Accepted
- Date: 2026-10-07
- Supersedes: none
- Extends: [0048](0048-generic-image-and-installed-module-storage.md)
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
[ADR 0007](0007-module-owned-drizzle-schemas.md),
[ADR 0008](0008-module-owned-migration-contracts.md),
[ADR 0009](0009-transactional-module-migrations.md),
[ADR 0016](0016-module-database-access.md),
[ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0023](0023-module-ui-build-pipeline.md),
[ADR 0028](0028-module-operational-lifecycle.md),
[ADR 0040](0040-module-stylesheets-and-artifact-isolation.md),
[ADR 0041](0041-module-browser-contributions.md),
[ADR 0044](0044-self-hosted-reference-deployment.md),
[ADR 0047](0047-module-ci-harness.md)
## Context
ADR 0048 took modules out of the platform image. An installation keeps them in its own module
storage, mounted read-only, and discovers them at a controlled restart. Each installed module must
be a self-contained directory: the platform lends it no dependencies. ADR 0048 deferred the format
a module is delivered in.
That gap is now the obstacle. Nothing yet produces an installed module directory except a test-only
Dockerfile, so a module release cannot be built, distributed, and installed on its own. An
investigation against the module template and Signage 0.1.2 established:
- **`dist/` alone does not run.** Compiled server and schema code imports `@ekklesia/module-sdk`
(and through it `@ekklesia/contracts`, zod, and semver) and `drizzle-orm` by name, and Node never
resolves those from the platform's `node_modules`.
- **Bundling works.** Bundling the template's server and schema with esbuild gave about 720 KB of
backend code and no `node_modules` at all. Inside the generic image, run as its unprivileged
user from a read-only mount at `/var/lib/ekklesia/modules`, it passed module, schema, and
migration validation, and its server entrypoint imported. Core's own Drizzle instance built a
query over the bundled module's table.
- **Native dependencies cannot be bundled.** Signage needs `sharp`, a Node-API addon whose
platform packages carry an 18.6 MB `libvips` shared library for one operating system, CPU, and
C library (Linux, x64, glibc ≥ 2.28). With its backend bundled and only `sharp` and its own
dependencies kept beside it, Signage was 22 MB and contained no link. It validated and processed
an image inside the same image.
- **`pnpm deploy` is the wrong tool.** Non-legacy mode refuses unless the whole workspace switches
to injected workspace packages. Legacy mode leaves a link escaping the package. Every mode
ships packages nothing loads at run time (React and its DOM, type packages, database drivers
pulled in as peers) and leaves `workspace:` ranges in copied manifests. Running it inside a
workspace rewrites that workspace's install state to production-only, which then breaks the
workspace's own builds. It did so in the investigation's scratch workspaces, and earlier in a
developer's own checkout.
- **A deterministic archive is cheap.** A tar archive with sorted paths, fixed owner and
timestamps, and a gzip header without a name or time produced the same SHA-256 twice. Gzip packed
Signage to 8.5 MB.
Module releases also need a contract that does not depend on where they are hosted. Ekklesia's
source and CI live on a Gitea server today; a marketplace, another host, object storage, or offline
media may carry the same release tomorrow.
## Decision
### Purpose
A separately versioned Ekklesia module is distributed as an **Ekklesia Module Package**: one
immutable, self-contained archive. Installing, updating, or removing a module never rebuilds the
platform image (ADR 0048).
The package contract is independent of marketplace discovery, of the source-code host, of any
artifact registry, of the church installing it, and of the URL it was fetched from. Catalogues,
marketplaces, and signing are separate concerns, and none of them is decided here.
### The artifact is a deterministic `.tar.gz`
Module Package v1 is a gzip-compressed tar archive. Its identity is its contents, never its
transport: the same archive may travel through Gitea, GitHub, an OCI registry, object storage,
offline media, or a future Ekklesia marketplace without any change to what it installs. No registry,
host, or OCI concept is part of the package format.
The canonical packager produces the archive **deterministically**: the same packager version and
the same inputs produce byte-identical output. At minimum:
- **Entries.** Every directory and regular file appears in byte-wise sorted path order, directories
before their contents.
- **Ownership.** uid and gid are 0 and the user and group names are empty.
- **Timestamps.** Every entry's modification time is 0, and the archive holds no access, change, or
creation time.
- **Modes.** Directories are `0755` and files `0644` (see [Archive safety](#archive-safety)).
- **No host metadata.** No extended attributes, ACLs, or archive-level headers that carry host or
time information.
- **gzip.** The header has no file name and a modification time of 0, at a fixed compression level.
The SHA-256 of the complete archive is distributed beside it, as a separate `.sha256` file in the
`sha256sum` format. The **recommended** file name is `<module id>-<module version>-<target>.tar.gz`,
for example `signage-0.1.2-linux-x64-glibc.tar.gz`. A file name is a convenience, never trusted:
what the archive is comes from its contents and its digest.
### What a package contains
A v1 archive contains exactly one top-level directory, named with the module id. Its contents are
the installed module directory ADR 0048 requires:
```text
<module id>/
package.json generated runtime metadata
ekklesia.module.json the module's manifest, unchanged
ekklesia.package.json package metadata (below)
THIRD_PARTY_NOTICES licence and notice material for everything bundled or included
dist/ bundled backend entrypoints, their shared chunks, and the declared
UI, display, and contribution artifacts
drizzle/ migration SQL, journal, snapshots, and _ekklesia.json, when declared
node_modules/ only when the package needs a native runtime dependency (below)
```
Every entrypoint the manifest declares exists at its declared path.
It contains nothing else, and in particular:
- source files, unless something at run time reads them;
- tests;
- TypeScript declarations;
- source maps, until a later decision permits them;
- development dependencies;
- any `workspace:` reference;
- any link (see [Archive safety](#archive-safety)).
**The runtime `package.json` is generated by the packager, never copied** from the module's
development workspace. It holds only what module discovery reads:
- `name` and `version` (equal to the manifest's version);
- `"private": true`;
- `"type": "module"`;
- `"ekklesia": { "layer": "module" }`.
It declares no dependencies, scripts, exports, or imports, because nothing at run time resolves
through them.
The manifest is copied unchanged. It stays the module's authoritative runtime contract, and its
`compatibility.platform` and `compatibility.moduleSdk` ranges stay the only place those are stated.
### Backend code is bundled
The canonical packager bundles the module's backend entrypoints (server, schema, and jobs) with
every pure-JavaScript dependency they use. That includes `@ekklesia/module-sdk`,
`@ekklesia/contracts`, and ordinary libraries. The bundles are ES modules at the manifest's declared
paths, and they may share chunks under `dist/`. Browser artifacts are already self-contained bundles
(ADR 0023, ADR 0040, ADR 0041) and are packaged as built.
The platform lends an installed module nothing (ADR 0048). **`node_modules` in a package is the
exception, not the default.** A dependency stays outside the bundle only when it must exist as
separate files at run time: a native Node addon, or a package that is equally platform-specific.
Then the package carries it, and exactly its own runtime dependencies, flat under
`node_modules/<name>`.
**The canonical packager decides what stays external, and validates it.** It refuses to package a
module whose bundled code still needs a file it did not include, or whose externals are not native
or platform-specific. Choosing which dependencies to leave unbundled is not part of the module
author's interface.
### Targets
Every package has one **target**:
- **`any`**: the package contains no native or platform-specific file. It runs wherever its declared
Node.js range is met.
- **`<platform>-<arch>[-<libc>]`**: the package contains native code for exactly one environment.
- `<platform>` and `<arch>` use Node.js's own `process.platform` and `process.arch` names.
- On Linux, `<libc>` is required and is `glibc` or `musl`. On other platforms it is omitted.
Examples: `linux-x64-glibc`, `linux-arm64-glibc`, `linux-x64-musl`. Signage, because of `sharp`, is
`linux-x64-glibc` for the current production image.
A package has exactly one target. A module that ships for several environments publishes one
package per target. Packages carrying several architectures at once are not part of v1.
### `ekklesia.package.json`
For example (the values are illustrative):
```json
{
"packageFormat": 1,
"module": { "id": "signage", "version": "0.1.2" },
"target": "linux-x64-glibc",
"runtime": {
"node": ">=24.11.0 <25",
"nodeApi": 9,
"libcVersion": ">=2.28"
},
"provenance": {
"revision": "86ae907f59458508e3277666cdd214f5bd7c1078",
"repository": "https://example.org/your-org/signage.git",
"packager": { "name": "@ekklesia/module-pack", "version": "0.2.0" }
},
"incorporates": {
"@ekklesia/module-sdk": "0.1.2",
"@ekklesia/contracts": "0.1.2"
},
"transitional": {
"databaseRuntime": { "drizzle-orm": "0.45.2" }
},
"files": [
{ "path": "THIRD_PARTY_NOTICES", "size": 48211, "sha256": "…" },
{ "path": "dist/server/index.js", "size": 1346, "sha256": "…" }
]
}
```
The fields are:
- **`packageFormat`**: the integer `1`. An installer refuses a format it does not implement. Format
versions are strict cutovers, like manifest versions.
- **`module.id`, `module.version`**: must equal the manifest's and the runtime `package.json`'s.
The top-level directory must be named with the id.
- **`target`**: as defined above.
- **`runtime`**: what the package needs from the Node.js runtime that will load it.
- `node`, a semantic-version range, is always present.
- A native package also states:
- `nodeApi`: the minimum Node-API version, for Node-API addons;
- or `nodeAbi`: the exact `process.versions.modules`, for an addon built against one Node
ABI;
- and on Linux, `libcVersion`: the minimum C library version.
- **`provenance`**: how the package was built.
- `revision`: the exact source revision, always present.
- `repository`: informational only. Nothing trusts it, and it ties the package to no host.
- `packager`: the packager's name and version.
- **`incorporates`**: the exact versions of the Ekklesia SDK and runtime libraries bundled into the
package. The packager refuses a package whose incorporated `@ekklesia/module-sdk` does not satisfy
the manifest's own `compatibility.moduleSdk` range, so the two can never disagree.
- **`transitional`**: compatibility facts that today's implementation needs and that are not part
of the long-term contract. See [Database runtime compatibility](#database-runtime-compatibility).
An installer must understand every key it finds there or refuse the package.
- **`files`**: one entry for every regular file in the module directory **except
`ekklesia.package.json` itself**, sorted by path. Each entry has its path relative to the module
directory, its size in bytes, and its SHA-256. The set is exact: a payload file missing from the
list, or a listed file missing from the payload, invalidates the package.
The archive's `.sha256` covers the whole archive, `ekklesia.package.json` included. The per-file
digests let an installer, or an operator later, check an extracted directory without the archive.
Platform and Module SDK compatibility stay in `ekklesia.module.json` and are not repeated here.
### Database runtime compatibility
Core runs a module's queries through the platform's Drizzle instance over tables the module defined
with its own copy of Drizzle (ADR 0016, ADR 0048). Those objects only interoperate when the two
copies are interchangeable. So while it is needed, a package bundles Drizzle with its backend code,
records the version in `transitional.databaseRuntime`, and an installer refuses a package whose
Drizzle version differs from the platform's.
**This is an implementation constraint of today's platform, not the Ekklesia Module Database ABI.**
Object identity and version agreement between two Drizzle copies are not a contract this ADR
establishes or that module authors may rely on. A separate Module Database ABI decision will replace
it, and that ABI will make the platform's ORM an implementation detail. When it lands, the
`transitional.databaseRuntime` requirement goes away under the format-versioning rules here.
### Licence obligations
Bundling copies third-party code into the module's own files, where the licence headers that came
with it may not survive. **Every package therefore carries `THIRD_PARTY_NOTICES`**: a generated
plain-text file at the module directory's root. It names each third-party package bundled into
or included in the package, with its version and licence, and holds the licence and notice text
that redistribution requires.
The packager's implementation must establish exactly how licence and notice material is collected
and preserved, including licence comments that bundling would otherwise strip. Until it does, no
package is treated as a distributable release.
### Archive safety
**v1 packages contain no links of any kind.** Neither symbolic nor hard links are permitted, so no
link can escape the package, and there is no case where one is allowed. Native dependencies were
packaged without any.
A conforming archive also contains no:
- absolute path;
- `..` path segment;
- path outside the single top-level module directory;
- device node, FIFO, or socket;
- setuid, setgid, or sticky bit.
It holds only directories and regular files.
**Extraction normalizes ownership and modes.** Everything is owned by the installation, not by the
uid or gid in the archive. Directories are `0755` and files `0644`, readable by the API's
unprivileged user and writable by nobody but the installer. No file in a v1 package is executable.
Loading JavaScript and native addons needs no execute bit.
An installer refuses an archive that breaks any of these rules, and never extracts it partially into
module storage.
### Installation model
This ADR records the intended installation lifecycle; it does not implement it.
```text
acquire the archive
→ verify the archive's SHA-256
→ extract into a hidden staging directory inside module storage
→ validate ekklesia.package.json, then every file's size and SHA-256
→ validate the target and runtime against this platform and host
→ validate the manifest and the complete candidate inventory: the installed modules, with this
candidate in place of any version of the same module
→ load the backend entrypoints once in an isolated, short-lived validation process
→ produce the migration plan
→ stop the API
→ swap the candidate into the active module directory, keeping the previous version
→ apply migrations, only with explicit operator approval
→ start the API
→ verify readiness
```
Details:
- **Hidden directories.** Staging and previous versions live in dot-prefixed directories inside
module storage. Module discovery ignores hidden entries (ADR 0048), so neither is ever loaded.
- **The swap.** It happens only while the API is stopped. It is as close to atomic as the
filesystem allows, and an interrupted swap is recoverable from the kept previous version.
- **Who writes.** Module storage stays read-only to Ekklesia. Whatever performs the installation
writes it from outside the API process (ADR 0048).
- **No hot loading.** Discovery still happens only when a process starts.
- **Migrations stay explicit.** Nothing in this lifecycle applies a migration on its own (ADR 0009,
ADR 0044).
### Rollback
- **Before any migration from the new version is applied,** restoring the previous module directory
and restarting is a supported rollback.
- **After forward migrations are applied,** restoring older code is **not** a supported rollback.
The older package lacks the newer migrations, so migration planning refuses it as a downgrade
(ADR 0048). Even running it would mean old code against a newer schema. A true rollback then
means restoring the matching database and storage backup, unless a future, module-specific
compatibility mechanism says otherwise.
There are no down migrations (ADR 0008, ADR 0009). An installer must make the point of no return
explicit: back up before applying, and offer code-only rollback only while no new migration has been
applied.
### Trust
The SHA-256 digest proves integrity: the archive is the one whose digest you were given. It says
nothing about who published it. Package signatures, publisher trust roots, marketplace verification,
and trust levels for third-party modules are deferred.
Installed modules remain trusted backend code under ADR 0048. A conforming package is not sandboxed,
and passing validation does not make a module safe; it makes it well-formed.
### Canonical tooling
Packages are produced by one Ekklesia-owned packager, proposed as the workspace package
`packages/module-pack` with the command `ekklesia-pack-module`, run in a module's CI. Its command
line is an implementation detail; this ADR defines what it must produce.
**`pnpm deploy` is not the packaging mechanism**, for the reasons in Context:
- it includes runtime dependencies nothing loads;
- it leaves `workspace:` metadata;
- it leaves links that escape the package, or needs a workspace-wide linking change to avoid them;
- it rewrites the install state of the workspace it runs in.
The repository's test-only packaging of the module template
(`scripts/lib/test-module-package.Dockerfile`) is replaced by the canonical packager when one
exists.
### Relationship to the module CI harness
ADR 0047, still proposed, is already marked for revision against ADR 0048. Its harness should
consume canonical module packages: a module's CI builds its package, and the harness installs that
exact archive into module storage of the generic image. The harness then tests the artifact a church
would install, not a workspace build of it. Until the packager exists, the harness and smoke tests
may keep their current test-only packaging.
## Consequences
- A module release becomes one file plus its digest, built once by the module's CI and installable
on any compatible Ekklesia installation without touching the platform image.
- Module authors gain a packaging step and a target per native environment. A pure-JavaScript
module ships one `any` package; Signage ships `linux-x64-glibc` packages for the current fleet.
- Packages are small and auditable: bundled code plus, only where needed, a native dependency
tree. There are no links and no development dependencies, and every file is listed with its
digest.
- Each package carries its own copy of the SDK and contracts. Version agreement with the platform is
checked when the package is built, against the manifest's `moduleSdk` range, and Core keeps
validating module definitions structurally (ADR 0048).
- Rolling back after a schema-changing update is a backup restore. Operators must take a backup
before approving migrations.
- Licence handling becomes the packager's responsibility, and until it is proven no package
counts as a release.
- The Drizzle requirement is visible in package metadata as `transitional`, so its eventual removal
is a recorded format change and not a silent behaviour change.
## Alternatives
- **`pnpm deploy` as the packager.** Rejected for the reasons in Context.
- **Copying the workspace's dependency tree without bundling.** Rejected as the default. It ships
far more than runs (49 MB against 22 MB for Signage), keeps `workspace:` ranges, and makes every
pure-JavaScript dependency a file-layout concern. Bundling is already how browser artifacts are
built.
- **Platform-lent runtime dependencies.** Rejected by ADR 0048.
- **Installing the SDK from a registry when the module is installed.** Rejected: installation would
need network access and a resolver on every installation, and an installation's code would depend
on what a registry served that day. Publishing the SDK packages for module *builds* is a separate,
compatible future step.
- **Module-author-chosen externals.** Rejected: an arbitrary external is an undeclared runtime
dependency the platform cannot verify, and it reintroduces the `node_modules` layout as a
contract.
- **An OCI artifact as the package format.** Not adopted for v1: it needs registry tooling and login
to install, and adds nothing to integrity that the digest does not already give. Nothing here
prevents carrying the same `.tar.gz` inside an OCI artifact.
- **`.tar.zst`.** Not adopted for v1: about 17 % smaller for Signage at a high compression level,
but about three times slower to produce there, and less universally available than gzip. A later
format version may add it.
- **Packages carrying several architectures at once.** Not adopted for v1: each extra architecture
adds about 18.5 MB for Signage, for a fleet that runs one architecture. A later format version may
allow it.
- **Permitting internal symbolic links.** Rejected: links are where escape checks are subtle, and
the native dependencies that motivated the question were packaged without any.
## Deferred
- Package signatures and publisher trust roots.
- A marketplace or catalogue, and registry or hosting-provider integration.
- An installer, and any installer UI; an automatic update service.
- The purge workflow (ADR 0048).
- Packages carrying several architectures at once.
- The stable Module Database ABI that retires `transitional.databaseRuntime`.
- Source maps and other debugging material in or beside packages.
- Publishing the Ekklesia SDK packages for module builds outside a platform workspace.
- Moving Signage to the new package format.
@@ -0,0 +1,556 @@
# 0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects
- Status: Accepted
- Date: 2026-10-07
- Supersedes (partial): [0016](0016-module-database-access.md) — for modules that declare ABI v1,
only its definition of the module database service as `Pick<NodePgDatabase, …>`, a facade over
Core's Drizzle instance, and its rule that every module receives the same `services` object.
One pool, no pool or connection string for modules, no database access during `register`, the
shutdown order, handler error sanitization, and the trust boundary remain accepted and unchanged.
Modules that do not declare ABI v1 keep ADR 0016's facade until it is removed (see
[Compatibility and deprecation](#compatibility-and-deprecation)).
- Amends: [0007](0007-module-owned-drizzle-schemas.md) — for modules that declare ABI v1, the
schema definition Core inspects is plain Ekklesia-owned metadata instead of Drizzle table objects.
Table namespacing, identifier rules, uniqueness, same-module foreign keys, atomic aggregation, and
sanitized discovery errors remain accepted and unchanged.
- Extends: [0049](0049-module-package-v1.md) — this is the Module Database ABI that ADR 0049 deferred
and anticipated. ABI v1 packages carry no `transitional.databaseRuntime`. `packageFormat` stays 1.
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
[ADR 0008](0008-module-owned-migration-contracts.md),
[ADR 0009](0009-transactional-module-migrations.md),
[ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
[ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0047](0047-module-ci-harness.md),
[ADR 0048](0048-generic-image-and-installed-module-storage.md)
## Context
ADR 0016 gave module code database access through `ModuleDatabase`, which is
`Pick<NodePgDatabase, "select" | "selectDistinct" | "selectDistinctOn" | "insert" | "update" |
"delete" | "execute" | "transaction">`. Core implements it as a frozen object whose eight methods are
bound to Core's own `drizzle(pool)` instance.
ADR 0048 then took modules out of the platform image, and ADR 0049 made each module release a
self-contained package that bundles its own copy of Drizzle. The two copies now meet at run time,
and an investigation of the current code (Drizzle 0.45.2) found the coupling runs deeper than a
type:
- **Module objects cross into Core's ORM.** A module builds queries from table, column, and `SQL`
objects made by its own Drizzle copy. Core's Drizzle instance then compiles and decodes them.
This only works while the two copies agree on internal symbols and entity kinds, which is why ADR
0049 records the module's Drizzle version under `transitional.databaseRuntime` and refuses a
mismatch.
- **Core's ORM objects cross into the module.** `transaction` hands module code Core's live
`NodePgTransaction`. At run time that object is a full Drizzle database: its session, dialect,
relational query builder, and `rollback()` are all reachable, whatever the `Pick` type says. A
reference kept after the callback settles still points at a connection that has returned to the
pool.
- **Result decoding is split between two ORMs.** Core's Drizzle session overrides `pg`'s parsers
for each query, so temporal values arrive as raw text. Then the module's column objects convert
them. Neither half is an Ekklesia contract. `pg`'s default parsers are also process-global and
mutable, and some lose information: `jsonb` numbers beyond double precision, and `numeric[]`
elements, are parsed to JavaScript numbers.
- **Errors are ORM and driver shapes.** A module that needs to recognize a PostgreSQL condition
reads it from whatever object Drizzle or `pg` throws. Drizzle 0.45 wraps driver errors in its own
error type, whose message carries the SQL text and the parameters.
- **Schema validation needs Drizzle objects.** Module validation, Doctor, and the package verifier
import a module's schema entrypoint and inspect it with Drizzle's `is(…, PgTable)`,
`getTableConfig`, and `PgColumn`. That is the same cross-copy dependency, at validation time. Core
extracts only plain facts from it: table names and foreign-key target tables.
Two findings show the dependency is unnecessary:
- **Migration execution is already independent of the ORM runtime.** Migration discovery reads SQL
files and JSON metadata, and the runner executes SQL text. Neither imports Drizzle. Drizzle Kit
snapshots are validated metadata; they are not needed to apply a migration.
- **The module side can compile to SQL alone.** Drizzle and Kysely both compile a query to
PostgreSQL text with `$n` placeholders and an ordered parameter list. Drizzle 0.45.2's own
`pg-proxy` driver, given only a callback that receives SQL text, parameters, and an array-or-object
row mode, ran `select`, `update`, and `insert … returning`, and decoded `timestamptz` and `int8`
text with the module's own column definitions. That driver has no transactions, which a
module-side adapter has to supply.
Signage 0.1.2, the first production module, uses `select`, `insert`, `update`, `delete`,
`transaction`, one `returning`, and SQL expressions in `set` clauses. It uses no `execute`, joins,
row locks, isolation options, or result row counts. None of its usage needs a Core ORM object. It
recognizes a uniqueness conflict by reading SQLSTATE `23505` from the thrown error. A stable,
Ekklesia-owned place for that code is desirable regardless of which ORM either side uses. (This
paragraph is motivation, not part of the decision.)
## Decision
### The goal: the ORM is an implementation detail on both sides
Ekklesia defines a stable, Ekklesia-owned **Module Database ABI** between Core and modules.
- **The boundary is PostgreSQL itself.** Exactly these concepts cross it:
- SQL text;
- ordered, bound parameter values;
- result rows and their metadata;
- transaction scopes that Core controls.
- **Core never receives or depends on a module's ORM or query-builder object.** That includes:
- Drizzle tables, columns, `SQL` objects, query builders, and transactions;
- Kysely objects;
- any other third-party ORM or runtime object.
- **Modules never receive Core's ORM objects, driver objects, or connections.**
- **Each side chooses its own tools.** A module may use Drizzle, Kysely, raw SQL, or any module-side
query builder that produces PostgreSQL text and parameters. Core may keep Drizzle, or replace it,
without any module noticing.
This ADR defines **Module Database ABI version 1**. The TypeScript names below are illustrative; the
concepts and the semantics are the contract.
### A correctness boundary, not a security boundary
**The Module Database ABI is a correctness and compatibility boundary. It is not a security
sandbox.** Installed modules remain trusted backend code running inside the API process with its
database role (ADR 0006, ADR 0016, ADR 0048).
Nothing in this ADR claims, and nothing built from it may claim, that the following can stop
malicious module code from reaching data that is not its own:
- the ABI itself;
- parsing or inspecting SQL;
- checking table prefixes when statements execute;
- `SET ROLE`;
- PostgreSQL roles.
A module sends arbitrary SQL by design, including SQL that changes its role back. It also runs
in-process, where it can reach the network and the environment without the ABI.
Table ownership remains what ADR 0007 made it: a static correctness check of declared tables and
foreign keys, run during validation. The ABI keeps that check honest by removing the ORM objects it
used to depend on. It adds no runtime enforcement.
### Declaring the ABI: manifest version 8
A module opts into the ABI explicitly, in its static manifest. The manifest is strict and changes by
hard cutover (ADR 0006, ADR 0014, ADR 0021, ADR 0029), so the declaration arrives with
**manifest version 8**. Conceptually:
```json
{
"manifestVersion": 8,
"database": { "abi": 1 }
}
```
- **`database.abi: 1`** means the module receives a Module Database ABI v1 executor. It never
receives the legacy facade.
- **No `database` field** means the module receives ADR 0016's legacy `ModuleDatabase` facade
during the deprecation window.
- **Any other ABI value** fails validation. A platform that does not implement a declared ABI refuses
the module rather than guessing.
- **Version 8 is a hard cutover, like every manifest version before it.** Core parses exactly one
manifest version; it never parses version 7 and version 8 side by side.
- An existing source module moves to version 8, and repacks, without changing any database code.
- A package already built with a version 7 manifest must be rebuilt.
- Module Package v1 has only just landed, and no published ecosystem depends on version 7
packages, so this costs a rebuild and nothing more.
In this ADR, a **legacy module** means a module with a version 8 manifest and no `database`
declaration, which therefore uses the legacy facade. It never means a module with a version 7
manifest.
Because the declaration is static, Core, the packager, and the verifier know a module's database
contract without loading its code. Because Core enforces it, an ABI v1 module cannot reach the
legacy facade, so nothing at run time can depend on Drizzle agreement.
The rest of manifest version 8 (field placement, validation messages, the template, and the
cutover itself) is an implementation detail of the change that introduces it.
### The executor
An ABI v1 module's database service is an **executor**. Conceptually:
```ts
query({ text, values, rowMode }) → { command, rowCount, rows, fields }
transaction(callback, options) → the callback's result
```
- **`text`** is exactly one PostgreSQL statement, using `$1 … $n` placeholders. **One `query` call
runs one statement.** This is a correctness and API rule, not a security boundary.
- The implementation upholds it with a reliable driver or protocol mechanism where one exists,
such as PostgreSQL's extended query protocol, which accepts a single statement per request.
- It does not require, and must not grow, an Ekklesia SQL parser.
- If an implementation cannot reliably uphold this rule, the implementing change stops and reports
the conflict. It does not weaken this ADR silently.
- **`values`** is the ordered list of bound parameters (see
[Parameters](#parameters)). Values are always bound and never interpolated into the text.
- **`rowMode`** is `"object"` or `"array"`.
- `"object"` rows are keyed by column name.
- `"array"` rows are positional, in `fields` order. An ORM that selects the same column name twice,
as joins do, needs this mode.
- Duplicate column names are invalid in object mode. Core rejects such a result rather than
silently dropping a column.
- With the current PostgreSQL and driver execution path, the result's shape is only known once
the statement has run. So a mutating statement can already have had its effects when the
result-shape error is reported.
- This is not a security guarantee. A caller that needs atomicity runs the mutation inside an ABI
transaction, where the error rolls the mutation back with everything else.
- **`command`** is PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`.
- **`rowCount`** is the number of rows PostgreSQL reports as processed, or `null` when it reports
none.
- **`rows`** are the result rows, decoded as in [Results](#results). `RETURNING` rows are ordinary
rows.
- **`fields`** are the ordered column metadata, present even when there are no rows: at least each
column's name and its PostgreSQL type OID.
The executor contains no ORM type, AST, query object, or schema object, and accepts none.
**One executor per module.** Each ABI v1 module receives its own executor, bound to its module id.
The module's `start` and `stop` hooks and its route handlers all receive that same object, which
keeps ADR 0016's guarantee that lifecycle code and handlers share one database service. Different
modules get different executors, so Core can attribute every statement to the module that issued it.
**Observability belongs to Core.** Core may record per-module timings, command tags, row counts, and
error codes. It never writes SQL text or parameter values to logs, diagnostics, Doctor output, or
HTTP responses.
**Session state is not part of the ABI.** Top-level `query` calls are separate autocommit statements
that may run on different pooled connections. A module must not rely on session-level state, and
must not change it. That includes:
- session `SET`;
- temporary tables;
- named prepared statements;
- `LISTEN`;
- session advisory locks.
Settings that must hold for one unit of work use `SET LOCAL` inside a transaction. Core may reset or
discard a connection that a module changed. `COPY`, cursors, streaming results, and named prepared
statements are not part of v1.
### Parameters
The ABI deliberately accepts a small set of PostgreSQL wire-safe values:
| Value | Sent as |
| --- | --- |
| `null` | SQL `NULL` |
| `boolean` | `true` / `false` |
| `number` | its JavaScript decimal text; `NaN`, `Infinity`, and `-Infinity` as PostgreSQL's `NaN`, `Infinity`, and `-Infinity` |
| `bigint` | its decimal text |
| `string` | the text, unchanged |
| `Uint8Array` | `bytea` |
| arrays of the above | a PostgreSQL array, nested for more dimensions |
Everything else is refused with a sanitized error before anything reaches PostgreSQL, rather than
being coerced. In particular:
- **`Date` is not an ABI parameter.** A JavaScript `Date` is an instant. Whether it should become a
`timestamp` or a `timestamptz`, and in which time zone, depends on a column type the ABI cannot
see. The module-side adapter converts it to explicit text first; for example, Drizzle's timestamp
columns already send ISO-8601 text.
- **Plain objects are not implicit JSON.** A module or adapter encodes JSON explicitly, as a string,
and the SQL casts it where needed.
- **Identifiers are never parameters.** Table and column names are part of the module's compiled
SQL text. The ABI provides no identifier interpolation.
### Results
Core owns one documented decoding table for ABI v1. It does not depend on `pg`'s global parser
registry, which module code could change, or on any ORM. Values that a generic conversion could
change, or change silently by time zone, arrive in a stable raw form:
| PostgreSQL type | Arrives as |
| --- | --- |
| any `NULL` | `null` |
| `bool` | `boolean` |
| `int2`, `int4`, `oid` | `number` |
| `int8` | `string`, its exact decimal text |
| `float4`, `float8` | `number`, including `NaN` and `±Infinity` |
| `numeric` / `decimal` | `string`, its exact text (`NaN` included) |
| `text`, `varchar`, `bpchar`, `name`, `uuid` | `string` |
| `date`, `timestamp`, `timestamptz`, `time`, `timetz`, `interval` | `string`: PostgreSQL's ISO text output |
| `json`, `jsonb` | `string`: the JSON text, not parsed |
| `bytea` | `Uint8Array` |
| arrays of any of the above | JavaScript arrays (nested for more dimensions), each element decoded by the same rule, with `null` elements as `null` |
| any other type | `string`: PostgreSQL's text output |
- **Temporal text is fixed for every module.** Core fixes the session's `DateStyle` (ISO),
`IntervalStyle` (`postgres`), and `TimeZone` (UTC) on every connection that serves the ABI. The
same instant therefore always produces the same text, whatever the server's or the host's local
zone.
- **`bytea` has one representation.** A value is a `Uint8Array`. A module must not rely on it being
any particular subclass.
- **Higher-level conversion belongs to the module side.** Converting text to `Date`, `numeric` to a
decimal type, `int8` to `bigint` or `number`, and JSON text to values is the adapter's job, or the
module's. The Drizzle column types Signage uses already accept exactly these raw forms.
Changing how any type arrives, including decoding a type that arrives as text today, requires a new
ABI version.
### Errors
Every failure the executor reports is a Core-owned, sanitized **`ModuleDatabaseError`**.
Conceptually:
```ts
ModuleDatabaseError { code, sqlState? }
```
The two fields mean different things:
- **`code`** is always present. It is a stable, Ekklesia-owned error **category**, meant for
programmatic handling. Its categories cover at least:
- PostgreSQL rejected the statement;
- a parameter value was refused;
- a transaction option was refused;
- the scope was closed or used incorrectly;
- the transaction could not be completed.
This ADR does not freeze the complete set or the exact names. Once published, a code keeps its
meaning.
- **`sqlState`** is optional. It is the five-character SQLSTATE that PostgreSQL itself reported, such
as `23505` (unique violation), `23503` (foreign-key violation), or `40001` (serialization failure).
It is present only when the database supplied one, and Core never invents one.
- Failures that the ABI detects itself have a `code` and no `sqlState`: a closed scope, an
invalid parameter type, or an invalid transaction option.
- Modules branch on `code` for ABI failures and on `sqlState` for PostgreSQL conditions.
- **It never carries:**
- SQL text or parameter values;
- the driver's message, detail, hint, constraint, table, or column names;
- a stack, a cause, connection details, or any Core, `pg`, or Drizzle object.
- **Modules recognize it structurally** (its name and its `code`), never with `instanceof`. A module
bundles its own copy of the SDK (ADR 0049), so class identity across the boundary is not a
contract.
This replaces modules depending on `DrizzleQueryError`, `pg`'s `DatabaseError`, or any other driver
or ORM error shape. An uncaught `ModuleDatabaseError` in a route handler is sanitized at the
handler boundary exactly as before (ADR 0015, ADR 0016).
### Transactions
Transactions are **callback-scoped**, and Core owns the connection throughout:
```ts
transaction(async (tx) => { … }, { isolation, readOnly })
```
- **Starting.** Core takes a connection from its pool and begins a transaction with the requested
options. It then calls the callback with `tx`, a Module Database ABI v1 executor scoped to that
transaction alone. No `pg` client and no Core ORM transaction is ever exposed.
- **Commit and rollback.** Core commits only when the callback's promise resolves and no operation
launched through `tx` is still outstanding. When the callback rejects, Core rolls back, and the
transaction call rejects with the callback's error. There is no `rollback()` method: to roll back,
the module throws.
- **All work is awaited inside the callback.** Module code must await every database operation it
starts through `tx` before its callback returns. What happens when work is still outstanding
depends on how the callback settled:
- **Resolved with work outstanding.** The module forgot an `await` and has used the scope
incorrectly. The transaction is never committed: Core rolls it back, and the transaction call
rejects with the scope-usage `ModuleDatabaseError`. A forgotten `await` therefore fails. It never
quietly changes what the transaction contains.
- **Rejected with work outstanding.** The callback's own rejection stays the transaction's error.
Core rolls back and does not replace that error, a PostgreSQL failure included, with the
scope-usage error just because sibling work was still settling. This is what happens when one
query in `await Promise.all([tx.query(a), tx.query(b)])` fails while the other is still running.
- Either way, outstanding work never leads to a commit.
- Core may let operations that already started finish, or settle, before it rolls back and releases
the connection, so that a pooled connection is never handed on mid-operation. Waiting for them
never turns into a commit.
- **Transaction control belongs to the callbacks.** Transaction-control SQL is forbidden through
`query`: `BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, and
every other statement that starts, ends, or marks a transaction. Modules control a transaction's
lifecycle only through the `transaction` and `savepoint` callbacks.
- This is a conformance rule for trusted module code, not a sandbox or a security guarantee.
- Core does not parse SQL to enforce it. Such a statement is valid PostgreSQL, so PostgreSQL
executes it before Core can see its result.
- Core detects a violation where the driver and protocol make that possible: from the statement's
command tag, and from the connection's transaction status. It then fails safely:
- the call rejects;
- the transaction's scopes refuse every later statement without sending it, because it would
otherwise run outside any transaction;
- Core never commits the enclosing transaction itself, and that transaction rejects too;
- the connection is destroyed rather than reused.
- Core does not claim to undo a transaction-control statement PostgreSQL has already executed. A
`COMMIT` sent through a transaction's `query` has already committed the work before it.
- The commit and rollback guarantees in this section apply to conforming use of the ABI.
- **Revocation.** When the callback settles, `tx` is revoked, and it stays revoked once the scope has
finished. Any later call on it fails with the
closed-scope `ModuleDatabaseError` and sends nothing to PostgreSQL. A `tx` that escapes its
callback can never run a statement on a connection that has gone back to the pool.
- **Options.** `isolation` is `read committed`, `repeatable read`, or `serializable`. `readOnly` is a
boolean. An unknown option or value is refused, never ignored. `deferrable` is reserved for a
later version.
- **No automatic retries.** Core never retries a transaction. A callback may have side effects
outside the database, so retrying on `40001` is the module's decision.
- **Failed statements.** PostgreSQL semantics apply unchanged. After a failed statement the
transaction is aborted, and later statements fail until the scope ends. A module that wants to
continue after a failure uses a savepoint.
**Savepoints** are callback-scoped too:
```ts
tx.savepoint(async (sp) => { … })
```
- **Naming.** Core generates every savepoint identifier. Modules never provide one.
- **Release and rollback.** A resolved callback releases the savepoint. A rejected one rolls back
to it, releases it, and rejects with the callback's error.
- **Outstanding work.** The awaiting rule for transactions applies unchanged.
- A savepoint callback that resolves with operations still outstanding is a scope-usage error.
The savepoint is rolled back, never released, and the call rejects with that error.
- A savepoint callback that rejects keeps its own error. The savepoint is rolled back as for any
rejection.
- Either way, outstanding work never leads to a release.
- **One active scope.** Only the innermost open scope may run statements. While a savepoint is
open, its parent executor refuses statements and new savepoints, so savepoint scopes can never
overlap.
- **Revocation.** `sp` is revoked when its callback settles, exactly like `tx`.
Nested transactions are savepoints. No nested-transaction API exposes a driver or ORM object.
**Timeouts and cancellation stay with Core.** The existing per-statement bounds of the pool
(`statement_timeout` and `query_timeout`) apply to every ABI statement. Core may also bound how long
a transaction stays open. Modules cannot change Core's timeout policy through the ABI. `AbortSignal`
and module-initiated cancellation are not part of v1.
### Module-side adapters
The ABI is ORM-neutral. The Module SDK may ship adapters **on the module side**, bundled into each
module like the rest of the SDK (ADR 0049):
- **A Drizzle adapter.** It turns the executor into a Drizzle database the module's own tables work
with. It may use Drizzle's `pg-proxy` driver, mapping row-array queries to `rowMode: "array"` and
the rest to `"object"`. It supplies `transaction` by opening an ABI transaction and binding a fresh
Drizzle instance to `tx`.
- **A raw-SQL helper.** A tagged template that turns interpolations into placeholders and values.
- **Later, a Kysely adapter.**
Adapter types and query-builder types are not part of the ABI. Core never learns, and never needs
to learn, that a module used Drizzle. A module may bundle any Drizzle version its adapter supports.
### Schema metadata
For an ABI v1 module, Core validates table ownership from **plain, Ekklesia-owned metadata**, not
from ORM objects. Conceptually:
```yaml
tables:
- name: care_team_members
foreignKeys:
- table: care_team_teams
```
- **What it is.** Plain data: strings, arrays, and records, with no functions or class instances.
Core validates its structure strictly.
- **What Core checks.** It is enough for the checks ADR 0007 already makes:
- the module namespace and identifier rules;
- uniqueness;
- foreign keys that stay within the module and target its declared tables;
- atomic, deterministic aggregation with sanitized issues.
- **What Core never does.** For ABI v1 modules, Core does not call Drizzle's `is(…, PgTable)`,
`getTableConfig`, or `PgColumn` checks on module-provided objects, and does not import an ORM to
understand the metadata.
- **How a module produces it.** A Drizzle helper in the Module SDK may derive it from the module's
own Drizzle tables, on the module side. A Kysely or raw-SQL module may write it by hand or produce
it another way.
- **What it promises.** The metadata is a declaration checked for consistency. It is exactly as
strong a correctness check as today's, and no stronger: the database enforces columns when the
migration runs.
Legacy modules keep the existing Drizzle-object path while legacy support exists. Where the metadata
lives (in the schema entrypoint's default export or in a static file) is decided by the change that
implements it. Either way, it must be readable without Core importing an ORM.
### Migrations are unchanged
Migration discovery, validation, and execution stay as ADR 0008, ADR 0009, and ADR 0048 define
them. The runner already executes SQL without an ORM runtime. Migrations stay forward-only and
explicit, and history is retained for absent modules.
The Drizzle Kit migration file format (journal, snapshots, and `_ekklesia.json`) remains the module
migration contract. Whether that build-tool format should become Ekklesia-owned is a separate
question, and this ADR does not change it.
### Module Package v1
ADR 0049's `packageFormat` stays **1**. For a module that declares ABI v1:
- **Drizzle is allowed in the bundle.** It is a module-side implementation detail, for example of
the adapter.
- **The packager omits `transitional.databaseRuntime`.** Core requires no Drizzle runtime agreement.
- **The verifier checks the ABI declaration and the plain schema metadata instead.** It refuses an
ABI v1 package that carries `transitional.databaseRuntime`. Such a package was made by a packager
that did not know the module's database contract.
A legacy module's package keeps today's `transitional.databaseRuntime` recording and validation for
as long as the legacy facade exists.
This needs no new package format. ADR 0049 already lets a format 1 package carry no `transitional`
facts. A platform from before this change cannot misread an ABI v1 package either: it refuses the
version 8 manifest.
### Compatibility and deprecation
The deprecation window applies to the **legacy database facade**, not to manifest version 7.
1. **Phase 1.** Core serves both contracts: the legacy facade to version 8 modules without a
`database` declaration, and an ABI v1 executor to modules that declare `abi: 1`.
2. **Phase 2.** The Module SDK and the module template use ABI v1. First-party modules move over
independently, each in its own change.
3. **Phase 3.** After at least one documented deprecation window, ending in a breaking Module SDK
release, the legacy facade may be removed. Its removal date, and how the manifest then expresses
the database contract, belong to that later decision.
The legacy facade is transitional. It is not part of the permanent module contract, and it keeps
its current behaviour until it is removed.
## Consequences
- A module's ORM, and its version, no longer has to match Core's. Core can upgrade or replace its
own Drizzle without rebuilding any ABI v1 module.
- `transitional.databaseRuntime`, recorded in ADR 0049 as an implementation constraint, disappears
for ABI v1 packages, as ADR 0049 anticipated.
- Modules get one stable error shape with SQLSTATE, and one documented result encoding that loses
no precision and does not depend on time zones.
- A transaction object can no longer outlive its callback or reach a pooled connection afterwards.
- Every module moves to manifest version 8. Already-built version 7 packages must be rebuilt.
Database code changes only when a module opts into ABI v1.
- An ABI v1 module bundles an adapter. Its Drizzle code mostly stays as it is: the query builders
are its own.
- Core takes on a documented decoding table, transaction scoping, savepoint management, and error
mapping that Drizzle did implicitly before. Each needs live PostgreSQL test evidence.
- Core must serve two database contracts during the deprecation window.
- None of this is a sandbox. A trusted module can still do anything the API's database role can.
## Alternatives
- **An ORM AST or query-object ABI.** Rejected. Anything richer than SQL text and parameters is one
ORM's internal representation, so it would tie the ABI to that ORM and its release cadence. The
only part worth keeping, the row mode and field metadata, is already in the executor.
- **An Ekklesia-owned query DSL.** Rejected. It would cover less of PostgreSQL than Drizzle or
Kysely, cost more to maintain than either, and still force every module to rewrite its queries.
- **A permanent, versioned Drizzle facade.** Rejected as the permanent contract. It would make Drizzle
version agreement a permanent install-time constraint. Core's transaction object would keep
reaching module code. Core could never change its ORM. It remains only as the transitional legacy
facade.
- **Exposing the pool, a client, or a connection string.** Rejected, as in ADR 0016. Connection
lifecycle, timeouts, and transaction cleanup belong to the platform.
- **SQL parsing or execution-time prefix checks as a security boundary.** Rejected. SQL can name
tables in too many ways, and a trusted module can send any SQL, so a parser would be bypassable
and a false promise. ADR 0016 rejected the same idea.
- **`SET ROLE` or per-module PostgreSQL roles as a security boundary.** Rejected. Any SQL the module
sends can reset the role, and in-process code can reach the database without the ABI. Roles in a
module's CI, as a test aid, would be a correctness tool, not a boundary.
- **`Date` as a direct parameter.** Rejected. Its meaning depends on the target column's type, which
the ABI cannot see. Adapters convert it explicitly.
- **`pg`'s default parsing as the result contract.** Rejected. It is process-global and mutable, it
loses precision for `jsonb` numbers and `numeric[]` elements, and it is not an Ekklesia contract.
- **Parsing manifest versions 7 and 8 side by side.** Rejected. Manifest versions are hard cutovers,
and the deprecation window is about the database facade, not the manifest.
## Deferred
- The implementation: Core's executor, decoding table, transactions, savepoints, errors, and their
tests.
- Manifest version 8's exact mechanics, and the template and fixture cutover.
- The Module SDK's Drizzle adapter and raw-SQL helper.
- A Kysely adapter.
- The legacy facade's removal date and the release that removes it.
- Redesigning the migration file format.
- SQL cancellation and `AbortSignal`.
- Stronger process or database isolation for modules.
- Moving Signage to ABI v1.
@@ -0,0 +1,207 @@
# 0051: Module packages declare their own license and carry its text
- Status: Accepted
- Date: 2026-10-07
- Amends: [0049](0049-module-package-v1.md) — every Module Package v1 artifact gains an
authoritative package-level license declaration and a `LICENSE` payload. `packageFormat` stays 1.
- Related: [the repository license](../../../README.md#license),
[ADR 0048](0048-generic-image-and-installed-module-storage.md),
[ADR 0050](0050-module-database-abi-v1.md)
## Context
ADR 0049 makes a module release one immutable, self-contained package and already requires
`THIRD_PARTY_NOTICES` for code bundled from third-party dependencies. It deliberately says that no
package is a distributable release until license and notice collection is proven. The implemented
packager now generates those notices from the dependency code it actually bundles, but Module
Package v1 still does not declare the module's own license or carry the module's license text.
Those are different obligations. `THIRD_PARTY_NOTICES` excludes both the module itself and bundled
Ekklesia-owned packages. The source module's development `package.json` is not copied into the
package: the packager generates a minimal runtime `package.json` instead. Consequently, an archive
can preserve every third-party notice and still lose the authoritative declaration and text for the
work being distributed.
The repository and its workspace packages declare `AGPL-3.0-or-later`, and the repository carries
the GNU AGPL version 3 text in `LICENSE`. The canonical module template also declares
`AGPL-3.0-or-later` in its source `package.json`. Official Ekklesia modules use that license today,
but a packager cannot assume that every module has the platform's license. A module release must say
what license its own publisher actually declared and must carry the corresponding material.
The current `ekklesia.package.json` contract is strict: a reader refuses unknown fields. Adding a
required field therefore makes pre-change and post-change readers reject one another's artifacts.
That is intentional here, not a reason for `packageFormat: 2`. ADR 0049 has not admitted a
distributable v1 release without proven license handling; this decision completes that still-open v1
requirement before real modules are distributed. The packager and verifier must adopt the tightening
together, and strict readers fail closed during that cutover rather than silently misreading a
package.
## Decision
### The package declares the module's own license once
Every Module Package v1 `ekklesia.package.json` has a required top-level `license` field containing
the module's SPDX license expression:
```json
{
"packageFormat": 1,
"module": { "id": "example", "version": "0.1.0" },
"license": "AGPL-3.0-or-later"
}
```
`ekklesia.package.json.license` is the authoritative package-release declaration. The expression is
not repeated in `ekklesia.module.json` or in the generated runtime `package.json`. The module's
source `package.json` is a packaging input, not release metadata inside the finished artifact.
The source module must explicitly declare its license in its own project metadata. The canonical
packager reads the normal string `license` field from the source module's `package.json`, validates
it as a supported SPDX license expression, and preserves that declaration in
`ekklesia.package.json`. It refuses a missing, empty, `UNLICENSED`, legacy object-shaped, malformed,
or unsupported declaration. It never substitutes the Ekklesia platform's license and does not try
to decide whether a publisher chose an appropriate license.
For official Ekklesia modules today the declaration is `AGPL-3.0-or-later`, matching the repository
license. This is a fact about those modules, not a default the packager invents for other modules.
### The package carries the applicable license material
Every distributable Module Package v1 contains a regular file at this canonical path inside the
module directory:
```text
LICENSE
```
`LICENSE` contains the license material applicable to the module release. It is an ordinary payload
file under ADR 0049:
- it appears exactly once in the strict `files` list in `ekklesia.package.json`;
- its byte size and SHA-256 are recorded there;
- the archive SHA-256 covers it and the metadata that records it;
- it is immutable for that release and changes only by producing a different archive.
The license bytes must be available as an explicit source or build input. The canonical source is a
module-owned `LICENSE` file at the module source root. Tooling may also accept an explicitly named
license-text input when a workspace layout needs one, but it must place those bytes at the package's
canonical `LICENSE` path. It must not search ancestor directories and silently adopt a repository
license, generate project-specific license material, or download text from the network. The
in-repository template implementation may later add its own `LICENSE` input or explicitly select
the repository's AGPL text; this ADR does neither.
The packager validates that both inputs exist: the declared SPDX expression and the applicable
license text. It preserves the supplied text as deterministic package input. It is not a license
compatibility engine and does not offer legal advice.
### `THIRD_PARTY_NOTICES` remains a separate concern
`THIRD_PARTY_NOTICES` continues to describe third-party packages actually bundled into or included
with the module: their identities, versions, declared licenses, uses, and required license and notice
texts. It is not the module's own license file and cannot satisfy the `LICENSE` requirement.
The module itself is not added to `THIRD_PARTY_NOTICES`. A first-party module is not duplicated there
merely because it uses the same AGPL license as Ekklesia. `LICENSE` and `THIRD_PARTY_NOTICES` both
remain required because they describe different parts of the release.
### Bundled Ekklesia code
A module package may bundle Ekklesia-owned SDK, contracts, database adapter, UI, and related code.
`incorporates` continues to record the exact versions of those Ekklesia components. It is an
inventory and compatibility fact, not a second authoritative declaration of the module's license.
Today's official module packages and the Ekklesia components they incorporate are all
`AGPL-3.0-or-later`. Their package `LICENSE` therefore carries the applicable AGPL text once. The
package does not need one identical copy per incorporated Ekklesia component, and those components
do not move into `THIRD_PARTY_NOTICES` merely to obtain duplicate text.
This ADR does not decide every future combination of a community module's license with differently
licensed incorporated code. Publishers remain responsible for their choices and obligations. The
packager preserves declared information and required material; it does not judge broad license
compatibility.
### Packaging and verification fail closed
The implementation that follows this ADR must make the packager refuse a module when:
- its source project has no acceptable explicit SPDX license expression;
- its license-text input is missing or is not a regular file;
- producing the package would omit `LICENSE` from the strict payload.
The package verifier must refuse an artifact when:
- `ekklesia.package.json.license` is missing, malformed, or outside the expression syntax the
contract supports;
- the `LICENSE` payload is missing;
- `LICENSE` is absent from the exact `files` list; or
- its listed size or SHA-256 does not match its bytes, as with every other payload file.
This ADR does not implement those checks. The contracts, packager, verifier, fixtures, template,
and package proofs must change together in the implementation PR.
### Determinism and runtime metadata
`LICENSE` introduces no timestamp, generated host path, network lookup, or other environmental
input. The deterministic archive rules in ADR 0049 apply to it unchanged.
The generated runtime `package.json` remains minimal and is not authoritative for licensing. There
is no runtime need to add a `license` field there. `ekklesia.package.json` is the release metadata,
while the source module's `package.json` only supplies the publisher's declaration to the packager.
### Package format remains 1
`packageFormat` remains **1**. This is an additive tightening that completes ADR 0049's licensing
requirement before the first distributable Module Package v1 release. It does not change the archive
encoding, safety rules, target model, installation layout, or meaning of any existing field.
The strict metadata schema is preserved. A verifier that predates this decision refuses the new
unknown `license` field; an updated verifier refuses an old package that lacks it or lacks `LICENSE`.
Both outcomes are safe. There is no installed or published v1 release compatibility promise to
preserve because ADR 0049 explicitly withheld distributable-release status pending this work.
ADR 0050's database ABI decision is unaffected. Whether a package uses database ABI v1 or the legacy
database facade has no bearing on its license declaration or payload.
## Consequences
- A Module Package v1 release is self-describing about the module's own license and carries the
corresponding material without consulting its source repository or the network.
- Operators can audit the release declaration in `ekklesia.package.json`, the module's license in
`LICENSE`, and dependency obligations in `THIRD_PARTY_NOTICES` without conflating them.
- Module publishers must make both the SPDX declaration and license text explicit packaging inputs.
- The template and every package fixture must be updated before packaging succeeds, but only in the
later implementation PR.
- Existing development artifacts produced before this tightening are intentionally rejected and
rebuilt; none was a distributable v1 release under ADR 0049.
- Identical AGPL text is carried once for today's official module and incorporated Ekklesia code.
## Alternatives
- **Use only `THIRD_PARTY_NOTICES`.** Rejected: it describes third-party dependencies and explicitly
excludes the module itself and Ekklesia-owned incorporated packages.
- **Make the generated runtime `package.json` authoritative.** Rejected: it is deliberately minimal
runtime discovery metadata, and copying the declaration there would duplicate release metadata.
- **Copy the platform's license automatically.** Rejected: the platform cannot declare a module's
license for its publisher, and a community module may make a different declaration.
- **Search parent directories for `LICENSE`.** Rejected: the chosen file would depend on checkout
layout and could silently apply an unrelated repository's terms. An out-of-tree input must be
explicit.
- **Download standard license text while packaging.** Rejected: builds must remain offline,
deterministic, and independent of a mutable remote source.
- **Duplicate the AGPL text for every incorporated Ekklesia package.** Rejected: identical copies
add no information. `incorporates` records component identity and version; one applicable text is
sufficient for today's official packages.
- **Create `packageFormat: 2`.** Rejected: v1 has not crossed its own distributable-release boundary,
strict readers fail safely, and no archive or installation mechanism changes.
- **Make the packager decide license compatibility.** Rejected: preserving declarations and material
is mechanical; judging every present and future license combination is a broader legal and policy
problem.
## Deferred
- Implementing contract, packager, verifier, fixture, template, and package-proof changes.
- The exact set and version of SPDX expression syntax accepted by the implementation, beyond the
requirement that unsupported expressions fail closed.
- Broad policy for future mixed-license community modules and incorporated code.
- Container-image license distribution.
- Marketplace, installer, publisher verification, and package-signing policy.
@@ -0,0 +1,229 @@
# 0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material
- Status: Accepted
- Date: 2026-10-07
- Extends: [0049](0049-module-package-v1.md), in one respect only. ADR 0049 left exactly how licence
and notice material is collected to the packager's implementation. That implementation, and the
module development guide, then took material only from a redistributed package's own files, or
from a predefined standard licence text that needs no project-specific lines. This ADR adds one
further source: a reviewed supplement. Everything else in ADR 0049 is unchanged, as is
`packageFormat`.
- Related: [ADR 0048](0048-generic-image-and-installed-module-storage.md),
[ADR 0051](0051-module-package-license-material.md),
[ADR 0055](0055-corresponding-source-artifacts.md) (decides the source-obligation policy deferred
below)
## Context
ADR 0049 requires every Module Package v1 to carry `THIRD_PARTY_NOTICES`: the licence and notice
material that redistributing its third-party code requires. It also states that no package is a
distributable release until licence and notice collection is proven. The canonical packager collects
material from two places:
- the licence, copying, copyright, and notice files a redistributed package ships at its root;
- the standard text of the package's declared licence, but only when that text is complete without
project-specific lines (Apache-2.0 today).
A package that has neither stops packaging. That is fail-closed by design: an MIT or BSD package
without its file is refused, because its copyright line exists only in that file.
The first native-target module package needs a dependency that this rule cannot handle.
`@img/[email protected]` is the prebuilt libvips that `sharp` loads on Linux x64 with
glibc. It declares `LGPL-3.0-or-later`. The package itself, like its latest release 1.3.4, contains
only these files:
- `README.md`
- `versions.json`
- `lib/index.js`
- one glib header
- one shared library
That shared library includes about 29 libraries. Its README names each one and the licence it is
used under, mostly with a link:
- LGPL components, such as libvips, glib, and pango;
- cairo, under MPL-2.0;
- many BSD-, MIT-, zlib-, IJG-, and FreeType-licensed libraries.
The package ships no licence text and no copyright notice for any of them. No standard text can stand
in for that material: it includes per-project copyright notices and several different licences.
Options that were ruled out before this decision:
- **Downloading material while packaging.** Packaging must stay offline and deterministic (ADR 0049,
ADR 0051).
- **Inventing material.** A packager that guesses notices produces artifacts that look compliant and
may not be.
Ekklesia needs a way to carry material that a dependency's own package omits. It has to be reviewed
by people, tied to exactly the bytes it was reviewed for, and fail closed whenever that tie breaks.
Some of these licences may also impose obligations beyond carrying text: the LGPL and the MPL can
require recipients to be able to obtain corresponding source. Whether and how such an obligation is
met for a particular redistributed binary is a legal and compliance determination. This decision
does not make that determination.
## Decision
### Package-provided material stays the preferred source
The licence, copying, copyright, and notice material that a redistributed package ships remains the
primary source, collected exactly as it is today. Nothing in this ADR removes, replaces, or rewrites
package-provided material. A supplement only adds to it.
### A reviewed supplement fills what a package omits
When a redistributed dependency omits material that redistribution requires, the canonical Ekklesia
packager may use a **license supplement**: a checked-in, reviewed record of that material for one
exact dependency.
- **Where supplements live.** In the Ekklesia repository, as part of the canonical packager's own
versioned inputs. A supplement enters only through Ekklesia's normal reviewed change process, and
the change that adds it is its review. A given packager version therefore always carries the same
supplements.
- **What that review is.** Repository review of a supplement is project and code review of a build
input: whether its content, its exact match, and its provenance are what the change says they are.
It is not legal review, and it does not prove that any redistribution obligation is satisfied.
- **What a supplement may contain:**
- licence texts;
- copyright and attribution notices;
- provenance: where each piece of material was obtained, and which upstream component versions it
covers;
- corresponding-source information, such as where upstream source for the exact build is
published;
- other material that accompanies the dependency's redistribution.
- **Generic by construction.** Supplements are data. The mechanism contains no package-specific code
and treats every supplement identically, whether it is for libvips or for any other dependency.
### A supplement matches one exact package, or it fails closed
A supplement names exactly one redistributed package:
- its package name;
- its exact version;
- an integrity value for the package's contents: a digest the packager computes from the package's
redistributed files.
- **Deterministic.** It covers exactly each redistributed file's normalized relative path within
the package and its bytes, combined in an order defined by the paths themselves.
- **Host-independent.** It excludes filesystem metadata, timestamps, ownership, permissions, and
the order in which files were extracted or listed. The same package contents always give the
same digest, wherever and however they were installed.
- **Documented.** The implementation defines and documents the exact algorithm, which does not
depend on any package manager.
The packager uses a supplement only when all three match the package it is about to redistribute.
- **A mismatch on any of the three fails packaging.** This covers, for example, a supplement whose
name and version match but whose integrity differs. It never falls back to a nearby version, and
it never silently skips the supplement.
- **Every match is used.** A supplement that matches is always merged into the notices, including
when the package also ships some material of its own.
- **Gaps still fail.** If a dependency lacks required material and no exact supplement matches, the
packager stops, exactly as it does today.
### Supplements are immutable build inputs
- **Immutable.** A supplement is never edited in place to cover a different build. A changed
package, a new version, or different contents need a new supplement, reviewed again.
- **No network.** Packaging reads supplements from the packager's own inputs and never downloads
anything. Assembling a supplement's content is a separate, offline-reviewed curation step done
when the supplement is authored, never during packaging.
### Merging is deterministic and labelled
The notice generator merges package-provided material and supplement material deterministically. The
same packager version and the same inputs still produce a byte-identical `THIRD_PARTY_NOTICES`
(ADR 0049).
- **Order.** Within a dependency's entry, package-provided material comes first, then supplement
material.
- **Labelling.** Supplement material is marked as supplied by Ekklesia's reviewed supplement, never
presented as if it came from the package.
- **No other change.** Dependencies are still sorted, line endings normalized, and each dependency
keeps its own entry, exactly as before.
### A supplement never changes a dependency's declared licence
`THIRD_PARTY_NOTICES` keeps reporting the licence the dependency itself declares.
- **No override.** A supplement can add material about the components a dependency contains, but it
cannot replace, override, or rewrite the dependency's declared licence.
- **Consistency check.** A supplement records the declared licence it was reviewed against. If that
disagrees with the package's actual declaration, packaging fails.
### Legal and compliance determinations are not made here
A supplement can record corresponding-source information, but this ADR does not decide what
satisfies any licence's source obligation, and neither does the mechanism:
- the mechanism never asserts that a written offer, a source URL, a mirror, or any other arrangement
satisfies a licence;
- carrying a supplement shows that the reviewed material is present in the package. It does not
certify that the package is compliant;
- for every redistributed binary under the LGPL, the MPL, or any other licence with source or
similar obligations, the determination of whether and how those obligations are met is deferred.
It must be made, and verified, separately.
ADR 0049's release gate still applies. A package that redistributes such a binary does not become a
distributable release just because its supplement was merged. It becomes one only once that
binary's compliance determination has been made and verified.
### Scope
- **Third-party code only.** Supplements apply only to redistributed third-party dependencies,
whether bundled or copied into the package. They never apply to the module's own licence or
`LICENSE` (ADR 0051), or to incorporated Ekklesia packages.
- **No package format change.** `packageFormat` stays **1**. A supplement changes only the content of
`THIRD_PARTY_NOTICES`, which is already a listed, digested payload file. No metadata field, archive
rule, or verifier contract changes.
### The motivating case
`@img/[email protected]` is the first dependency expected to need a supplement. This ADR
does not curate it. Its supplement will be assembled, reviewed, and merged separately, together with
the native-target packaging work that redistributes it. The source-obligation determination for its
LGPL and MPL components remains open, as above.
## Consequences
- A dependency whose package omits required material can be redistributed after review, offline and
deterministically, instead of making native packaging impossible.
- Each supplement is tied to exact package bytes, so a changed or unexpected dependency fails
packaging rather than inheriting material reviewed for something else.
- Ekklesia takes on reviewing and maintaining supplements. Every version bump of a supplemented
dependency needs a new reviewed supplement before packaging works again.
- `THIRD_PARTY_NOTICES` can now contain material that did not come from the dependency's package. It
is always labelled.
- Carrying material and meeting obligations stay separate. Source-availability questions remain open
until they are determined per binary.
## Alternatives
- **Download licence material while packaging.** Rejected: packaging must stay offline,
deterministic, and independent of a mutable remote source (ADR 0049, ADR 0051).
- **Extend the standard-text list.** Rejected: standard texts only work when the licence needs no
project-specific line. Bundled native libraries need per-project copyright notices, and several
different licences.
- **Match supplements by name and version alone.** Rejected: identical versions can differ in content
across registries or rebuilds, and the material was reviewed for specific bytes.
- **Fall back to the nearest supplemented version.** Rejected: material reviewed for one build says
nothing reliable about another.
- **Let a supplement restate a dependency's licence.** Rejected: the declaration belongs to the
dependency's publisher. A supplement that could override it could misrepresent what is being
redistributed.
- **Per-package special cases in the packager** (for example, code that knows about libvips).
Rejected: every supplemented dependency would need new code, and review would be split between
code and data.
- **Treat a recorded source URL or written offer as satisfying copyleft obligations.** Rejected for
this ADR: that is a legal determination that has to be verified per binary, not assumed by a build
mechanism.
- **Supplements provided by module authors.** Not adopted now: the canonical packager uses only
supplements reviewed into Ekklesia. Whether module repositories may contribute their own reviewed
supplements is deferred.
## Deferred
- Implementing the mechanism in the canonical packager: the supplement format and storage location,
the integrity algorithm, the matching, the merge, and the tests.
- Curating and reviewing the supplement for `@img/[email protected]`.
- The legal and compliance determination of source obligations for each redistributed binary, and
how a determination is recorded.
- Whether the packager should refuse, or flag, packages whose supplements note an open determination.
- Supplements provided by module repositories.
- Recording which supplements were used in `ekklesia.package.json`, should that ever be needed.
@@ -0,0 +1,255 @@
# 0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol
- Status: Accepted
- Date: 2026-10-07
- Superseded in part by: [0054](0054-public-package-reads-before-launch.md) — only where this ADR
describes the package phase before launch as private, or requires authenticated package reads.
- Extends: [0049](0049-module-package-v1.md). This is the step ADR 0049 deferred: "publishing the
Ekklesia SDK packages for module builds outside a platform workspace". A registry is used only to
build a module. Installing one stays registry-free: an installed package is self-contained, as ADR
0049 decided.
- Related: [ADR 0002](0002-pnpm-workspace-and-typescript.md),
[ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
[ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0023](0023-module-ui-build-pipeline.md),
[ADR 0039](0039-shared-design-tokens-and-elements.md),
[ADR 0047](0047-module-ci-harness.md),
[ADR 0048](0048-generic-image-and-installed-module-storage.md),
[ADR 0050](0050-module-database-abi-v1.md),
[ADR 0051](0051-module-package-license-material.md),
[ADR 0052](0052-reviewed-license-supplements.md)
## Context
Modules are independently versioned products in their own repositories (ADR 0021). Yet no module can
be built outside Ekklesia's monorepo today. Signage's continuous integration clones the platform at
a pinned commit, places Signage under `modules/signage`, and installs and builds inside that
workspace, because its Ekklesia dependencies exist only as `workspace:*` packages.
An investigation of the current packages found the following.
- **One coordinated version.** Every `@ekklesia/*` package, and the platform, is version 0.1.2. The
release process already bumps them together. The platform reports its Module SDK version, which
manifests' `compatibility.moduleSdk` ranges are checked against (ADR 0006), by reading
`@ekklesia/module-sdk`'s own package version.
- **The tarballs already stand alone.** `pnpm pack` rewrites `workspace:*` ranges to exact versions,
and each tarball carries only its built `dist`, declarations, and `LICENSE`. But every package's
`prepack` runs a command at the monorepo root, and `@ekklesia/typescript-config` is marked
`private`.
- **The SDK exposes everything.** `@ekklesia/module-sdk` re-exports the whole `@ekklesia/contracts`
barrel. That includes contracts no module needs, such as authorization and identity, and the
legacy Drizzle `ModuleDatabase` type. `@ekklesia/contracts` depends on `drizzle-orm` for that type,
so every module install pulls Drizzle in.
- Neither the module template nor Signage imports `@ekklesia/contracts` directly.
- **Frameworks are declared twice.** `@ekklesia/ui` lists `react`, and `@ekklesia/module-sdk` lists
`drizzle-orm`, as both a dependency and a peer dependency. That invites a second copy of a runtime
that must be single within one module bundle.
- **The canonical packager assumes the monorepo**, in two ways:
- it finds the module's Node.js range only through an enclosing `pnpm-workspace.yaml`;
- it decides that an Ekklesia package is the platform's own, not third-party, because its real
path does not contain `node_modules`.
An `@ekklesia/*` package installed from a registry would therefore be treated as third-party.
Packaging would omit it from `incorporates` and skip the Module SDK compatibility check.
- **The registry and its hosts.**
- The current private host is a Gitea 1.27.2 server. Its npm registry is enabled for the
organization, and that endpoint and the repositories are currently readable anonymously.
- pnpm leaves a tarball URL out of its lockfile only when the URL has the canonical
`<registry>/<name>/-/<name>-<version>.tgz` form. Gitea advertises another form, so lockfiles
produced against Gitea name the Gitea host.
- The integrity hashes in those lockfiles are independent of the host.
## Decision
### The npm registry protocol, not a provider
Ekklesia's module-development packages are distributed over the **standard npm registry protocol**,
and modules install them with ordinary pnpm or npm commands.
- **No provider is part of the architecture.** Not Gitea, GitHub, npmjs, or any other. A module
repository depends on the protocol and on a scope-to-registry mapping in normal npm/pnpm
configuration.
- **Gitea may host the private phase.** It is a host for this phase, not a permanent decision.
- **No host-neutral lockfile or Ekklesia proxy is required.** Moving to another registry may need a
one-time change of a module's registry configuration and a regeneration of its lockfile. The
integrity hashes keep verifying the package content across such a move.
A module repository's registry configuration is, for example:
```ini
# .npmrc, committed: where the @ekklesia scope lives, and nothing secret.
@ekklesia:registry=https://<registry endpoint>/
```
```ini
# User or CI configuration, never committed: the credential, from the environment.
//<registry endpoint>/:_authToken=${EKKLESIA_PACKAGES_TOKEN}
```
### The public package set
Only packages a module author needs are published. Nothing is published merely because it exists in
the workspace.
| Category | Packages |
| --- | --- |
| Author API | `@ekklesia/module-sdk`, `@ekklesia/module-database`, `@ekklesia/module-ui-sdk`, `@ekklesia/design-tokens` |
| Tooling | `@ekklesia/module-ui-build`, `@ekklesia/module-pack`, `@ekklesia/typescript-config` |
| Pre-1.0 component API | `@ekklesia/ui`: publishable, explicitly unstable before 1.0 |
| Implementation dependency only | `@ekklesia/contracts`: may be published because the packages above depend on it, but it is **not** a supported direct module-author API |
| Never published | `@ekklesia/core`, the applications, `@ekklesia/module-ui-host`, `@ekklesia/module-template`, and every other platform internal |
### A curated SDK surface
Before the first external publication, the author-facing SDK is narrowed.
- **A curated API.** `@ekklesia/module-sdk` exposes what a module author needs, and no longer
re-exports the complete `@ekklesia/contracts` barrel.
- **No forced Drizzle.** The stable SDK root does not make Drizzle a dependency of modules that do
not use it.
- **No legacy database contract.** The legacy Drizzle database contract (ADR 0016's facade type) is
not part of the standalone author-facing surface. Core still serves the legacy facade for its
deprecation window (ADR 0050). This decision only keeps its type out of what new standalone
modules build against.
- **Drizzle support lives in one place.** Drizzle-specific helpers belong to the
`@ekklesia/module-database` Drizzle adapter (ADR 0050).
### Dependency rules
These rules apply to the published packages:
- **React and ReactDOM** are peer dependencies of the public UI packages, plus development
dependencies for those packages' own builds and tests. This does not change ADR 0023: a module UI
artifact still bundles React. The peer only ensures there is one React in that bundle, the
module's own.
- **Drizzle** is an optional peer dependency only where the Drizzle adapter needs it.
- **Real implementation dependencies,** at run time or build time, remain normal dependencies.
These rules apply to a module repository:
- **Runtime and module-code packages** go in `dependencies`: the Author API packages, `@ekklesia/ui`,
React, and the module's ORM if it uses one.
- **Build tooling and configuration** go in `devDependencies`: `@ekklesia/module-ui-build`,
`@ekklesia/module-pack`, `@ekklesia/typescript-config`, and type packages.
### Versioning
- **One coordinated version during 0.x.** Every public Ekklesia module-development package uses one
release version, equal to the platform release version. The version the platform reports for
`moduleSdk` compatibility is therefore the version of the packages a module was built with.
- **No mixed trains.** Within a published train, Ekklesia-owned dependencies name the coordinated,
tested version, never a version from another train.
- **Ranges in source, exact artifacts in lockfiles.** A module may declare compatible ranges in its
source `package.json`; its lockfile pins exact artifacts with their integrity hashes.
- **`@ekklesia/module-pack` is pinned exactly.** A module repository pins it in its source
`package.json`, not only in its lockfile, because its version affects the package it produces.
- **Independent package versions are deferred.**
### Standalone packaging
- **No monorepo checkout.** A standalone module builds and packages from its own repository and its
installed dependencies.
- **Its own Node.js range.** The module declares its own Node.js compatibility, preferably in
`engines.node`, and the canonical packager reads it from there or from an explicit option. It
never requires an enclosing `pnpm-workspace.yaml`.
- **Ekklesia packages recognized by identity.** The canonical packager recognizes Ekklesia-owned
installed packages by explicit package identity and metadata, never by workspace path. So
`incorporates` and the Module SDK compatibility check (ADR 0049) work the same whether a package
came from the monorepo or from a registry. Ekklesia packages are never reported as third-party
software (ADR 0051).
- **No monorepo-root commands at install time.** Publishing workflows for the public packages run
their build in the platform's release pipeline. Nothing in a published package runs a
monorepo-root command when a consumer installs or packs it.
### Supply chain
- **Immutable versions.** A version is never republished with other content, and never withdrawn to
be reused.
- **Lockfiles.** pnpm lockfile integrity is part of the reproducibility model: modules install with a
frozen lockfile.
- **Release CI proves the tarballs before publication.** It must:
1. build and pack the public package set;
2. inspect every tarball: only the intended contents, a `LICENSE`, and no `workspace:` range;
3. install those tarballs into a clean standalone module fixture, outside any monorepo;
4. prove there that the fixture builds, packages with the canonical packager, and verifies.
Only then are the versions published.
- **Signing and provenance are deferred.**
### Registry access and the private phase
- **No secrets in module repositories.** Registry configuration for the scope is normal npm/pnpm
configuration.
- **Credentials from the environment.** Read and publish credentials come from environment or user
configuration: a read credential in module CI where reads need one, and a publish credential only
in Ekklesia's release pipeline.
- **No permanent provider.**
- **Verify access before the first publication.** Check the chosen registry's actual private and
read-access behaviour first. Ekklesia's packages must not be published unintentionally to a
registry that serves them anonymously while Ekklesia is not public.
### The release workflow
During 0.x, public package releases form a **coordinated SDK train** associated with platform
releases:
1. build;
2. pack;
3. standalone tarball smoke (see [Supply chain](#supply-chain));
4. publish immutable versions;
5. downstream module CI installs and tests those versions.
### Signage
The intended result is a normal standalone Signage repository:
- versioned `@ekklesia/*` dependencies;
- its own lockfile;
- continuous integration that installs, builds, packages, and verifies on its own.
That CI must not clone Ekklesia or place Signage under `modules/`. The migration itself is a separate
change.
## Consequences
- A module repository is an ordinary pnpm project. Clone it, install, build, and package it with the
canonical packager, with no Ekklesia checkout.
- Module authors see a smaller, deliberate API, and the platform can change its internal contracts
without breaking them.
- One coordinated version keeps compatibility simple: SDK version X is what platform version X
reports and accepts. The cost is republishing unchanged packages with each platform release.
- A registry move costs each module a configuration change and a lockfile regeneration, but no
source change. Package content stays verifiable by integrity hash throughout.
- Before the first publication, the SDK surface must be curated and the packager's two monorepo
assumptions removed. Both are breaking for code that relied on them.
- ADR 0047, still proposed, assembles a module into the platform workspace to test it. Before it is
accepted it must instead consume published packages and canonical module packages, consistent with
this decision and with ADR 0048.
## Alternatives
- **Git URL, tag, or commit dependencies.** Rejected: they tie modules to one Git host, need a build
on every install because `dist` is not committed, and cannot express version ranges.
- **Release-asset tarball URLs.** Rejected: each dependency becomes a host-specific URL, and ranges
cannot be resolved.
- **OCI artifacts for packages.** Rejected: npm and pnpm cannot install from them. (A harness image
is a different matter; see ADR 0047.)
- **Vendoring Ekklesia packages into each module repository.** Rejected: the copies drift, updates
are manual, and there would be no single canonical SDK.
- **Requiring host-neutral lockfiles, or an Ekklesia registry proxy that rewrites tarball URLs.** Not
required: a one-time lockfile regeneration on a registry move is an acceptable cost, and integrity
hashes already keep content verifiable. Ekklesia may still choose a stable endpoint later.
- **Publishing `@ekklesia/contracts` as a direct author API.** Rejected: it holds platform-wide
contracts. Modules get a curated surface through the SDK packages.
- **Externalizing React from module UI bundles to share one runtime.** Rejected again: ADR 0023's
reasoning stands, and peers only prevent duplicates within one bundle.
- **Independent versions per package now.** Deferred: during 0.x a coordinated train is simpler for
module authors and matches how the platform reports its SDK version.
## Deferred
- Provisioning a registry, and its credentials.
- Public publication on npmjs or any other public registry.
- Package signing and provenance.
- Independent package versioning.
- Dashboard and UI redesign.
- The Signage migration itself.
@@ -0,0 +1,85 @@
# 0054: Ekklesia's module-development packages are publicly readable before launch
- Status: Accepted
- Date: 2026-10-08
- Supersedes (partial): [0053](0053-standalone-module-development-packages.md) — only where it
describes the package phase before launch as private: "Gitea may host the private phase", the
read credential for module CI in "Registry access and the private phase", and its requirement
that packages not be served anonymously while Ekklesia is not public. Every other part of that ADR
remains accepted and unchanged: the npm registry protocol, the public package set and its
boundaries, the curated SDK, the dependency rules, the coordinated package train, exact release
artifacts, standalone module builds, provider portability, supply chain and immutability, and the
credential handling for publishing.
- Related: [ADR 0049](0049-module-package-v1.md)
## Context
ADR 0053 decided how Ekklesia's module-development packages (`@ekklesia/*`) are built, released, and
distributed over the npm registry protocol. It assumed that the phase before Ekklesia's public
launch would be private: module CI would carry a read credential where reads needed one, and the
first publication had to make sure the registry would not serve the packages anonymously.
Checking the chosen host before the first publication showed what keeping the packages private would
take:
- Gitea 1.27.2 decides package read access by the package **owner's** visibility, never a
repository's. The `River_of_Glory_Church` owner is a public organization, so its packages are
readable anonymously.
- That organization's repositories, Ekklesia's source among them, are already publicly readable.
- The instance allows self-registration, so "limited" visibility, readable by any signed-in user,
is readable by anyone who creates an account.
- Private packages would therefore need a separate private package owner, plus reader accounts and
read tokens for every module repository, every module's CI, and every developer.
The project owner has decided that Ekklesia is not secret. Its source is public; the project is
simply not being launched or promoted yet. Hiding the packages built from that public source would
protect nothing, and would still cost every module author an account and a token.
## Decision
1. **Ekklesia's module-development packages may be anonymously readable before the project's public
launch.** This covers the public package set of ADR 0053: search, metadata, and download.
2. **Readability is not a launch, and promises nothing more.** Public readability does not mean:
- that Ekklesia has been publicly launched;
- that the APIs are stable beyond the project's pre-1.0 SemVer policy;
- that third-party production use is supported;
- that a marketplace or public module ecosystem exists.
3. **Reading needs no authentication in this phase.** Installing `@ekklesia/*` needs no account,
reader account, read token, or authenticated install workflow. A module repository configures only
the scope's registry.
4. **Publication stays authenticated and tightly controlled.** Only Ekklesia's release process
publishes, with one dedicated publisher credential that holds nothing beyond package write
access, used only by that process, as ADR 0053's credential handling for publishing already
requires.
5. **Published versions stay immutable by Ekklesia policy.** A published version is never deleted or
republished with other bytes, whatever the registry technically allows.
6. **The current Gitea owner is an acceptable host.** The `River_of_Glory_Church` package namespace
on the current Gitea may host this phase, as long as publishing permission stays restricted to
the publisher. No private package organization is needed.
7. **A public launch is a separate decision.** Launching or promoting Ekklesia publicly, and anything
that changes for its packages then, is a product and release decision this ADR does not make.
## Consequences
- **Module authors and module CI need no Ekklesia credential.** The committed `.npmrc` scope mapping
is the whole registry configuration. ADR 0053's example of a read credential in user or CI
configuration is no longer needed in this phase.
- **ADR 0053's pre-publication access check changes meaning.** It now confirms that anonymous reads
work as intended and that publishing is refused without the publisher's permission, rather than
that reads are private. The disposable-registry suite proves both, and the publication tool can
require an anonymous download to match each published artifact.
- **Restricting publishing is now the whole access boundary**, so the publisher credential's scope,
where it is held, and who can reach it matter all the more.
- **Making the packages private again would be a new decision.** Package names, versions, and
contents already published stay public, because a published version is never deleted.
## Alternatives considered
- **A private package organization on the same Gitea.** Rejected: it hides nothing that is not
already public in the source, and it requires reader accounts and tokens for every module
repository, its CI, and every developer.
- **"Limited" package visibility.** Rejected: with self-registration open, anyone can read anyway,
while the configuration suggests otherwise.
- **Requiring sign-in for the whole instance.** Rejected: it would also hide the public
repositories.
- **Another registry for a private phase.** Unnecessary once reads are public.
@@ -0,0 +1,189 @@
# 0055: Native redistributable packages carry corresponding-source artifacts
- Status: Accepted
- Date: 2026-10-09
- Extends: [0049](0049-module-package-v1.md). ADR 0049 withholds distributable-release status from a
package until its licence handling is proven. This ADR adds what proving it means for a native
package that redistributes copyleft code: a sibling corresponding-source artifact, and a
fail-closed gate for public distribution. Module Package v1 is unchanged, and `packageFormat`
stays **1**.
- Decides: the item [ADR 0052](0052-reviewed-license-supplements.md) deferred, "the legal and
compliance determination of source obligations for each redistributed binary, and how a
determination is recorded", as a project policy. Reviewed supplements are unchanged in purpose
and format.
- Related: [ADR 0051](0051-module-package-license-material.md), whose rules for a module's own
licence are unchanged.
## Context
A native Module Package (ADR 0049) copies native dependencies into its `node_modules` exactly as
built. The first one, the packager's native proof with sharp, redistributes
`@img/sharp-libvips-linux-x64`. That is one shared object with about 29 libraries built into it.
Several are under the GNU LGPL, and cairo and some Rust crates are under the MPL.
ADR 0052 made the licence and notice side work. A reviewed supplement adds the licence, copyright,
and notice files the package omits, with the exact upstream source each came from. But ADR 0052 was
explicit that carrying material is not meeting obligations. The LGPL and the MPL also oblige whoever
distributes the binary to make the corresponding source available. For a library linked into a
larger work, that includes what a recipient needs to rebuild the library and use a modified version.
Recording upstream URLs does not do that: URLs move, tags are rewritten, and a mirror is somebody
else's promise.
Until this was decided, every native package containing such components was blocked from public
distribution, and the blocker had no defined way to be lifted.
## Decision
### Native packages may contain copyleft native dependencies
A native Module Package may redistribute native dependencies under the LGPL, the MPL, or a similar
licence with source obligations. It may be **distributed publicly** only when it is released
together with a **corresponding-source artifact** that meets this ADR. A package whose artifact
cannot be produced and verified is not approved for public distribution.
This is Ekklesia's **project compliance policy**: a conservative rule the project holds itself to.
It is not legal advice, and it is not legal certification. Repository review of the artifact, its
inputs, and its instructions is project and code review. It does not replace professional legal
review, and nothing built from this ADR may claim that it does.
### Supplements and source artifacts do different jobs
- **A reviewed supplement (ADR 0052)** identifies and verifies the licence and notice material for a
redistributed binary. It travels inside the package, in `THIRD_PARTY_NOTICES`.
- **A corresponding-source artifact** carries the source, patches, build definitions, and rebuild
and relink instructions for the covered native components. It travels beside the package, as a
separate release artifact.
A supplement alone never satisfies the source-availability policy. A source artifact never replaces
the supplement or `THIRD_PARTY_NOTICES`.
### What the artifact contains
For each covered native dependency the package redistributes, the artifact contains:
- the exact source the binary was built from, as bytes, not as references:
- its build definitions;
- the source archive of every library compiled into it;
- every patch the build applied;
- every source package a language package manager resolved for the build;
- the source of any runtime library the toolchain linked into it;
- the licence and copyright material: the reviewed supplement, and the files inside the sources;
- **instructions** that are technically actionable. They name what the artifact corresponds to and
its target, and say how to apply the patches and rebuild the covered library. They also say how a
rebuilt or modified library replaces the redistributed one in the installed module, and which
toolchain and configuration to use, or point to the included build definitions;
- a deterministic, machine-readable manifest of every component and file, each with its digest,
plus the human-readable provenance.
Components that are not part of the binary are left out, each with its reason recorded: build-only,
test-only, or another platform's. The exception is a build-time input needed to rebuild the covered
library, such as a source package a build script requires. That is included and marked as
build-only. Host system libraries and general-purpose build tools are not redistributed, and the
instructions name the versions they need.
### It is tied to exact inputs, and verified before it is written
- **Bound to the binary.** An artifact names each covered dependency by package name, version, and
the content digest a supplement is matched by (ADR 0052). It names the module identity and target
of the package it accompanies. An artifact for one version of a binary never satisfies another.
- **From reviewed inputs only.** The inputs come from a checked-in, reviewed **source recipe** per
covered dependency version. The recipe records each component's name, version, origin,
relationship to the binary, and licence material, and each source file's and patch's size and
SHA-256.
- **Acquisition is separate from construction.** Obtaining the recorded bytes may use the network,
in an explicit preparation step that stores them in a local cache keyed by content digest.
Constructing the artifact is offline. It reads only the cache and checked-in files, verifies every
byte against the recipe before writing anything, and never fetches or trusts a current upstream
response.
- **Deterministic.** The same inputs produce the same artifact, byte for byte, in any working
directory: normalized relative paths, fixed ownership, modes, and timestamps, sorted entries, and
no machine-specific path.
### Binary and source are linked by recorded digests
A public release of a native package with covered components consists of sibling artifacts:
- the Module Package archive and its SHA-256 (ADR 0049);
- the corresponding-source archive and its SHA-256;
- machine-readable **redistribution metadata** recording:
- the package archive's name and SHA-256;
- the module identity and target;
- each covered dependency's name, version, licence, and content digest;
- the supplement and recipe it was assembled from;
- the source archive's name and SHA-256, and the SHA-256 of its manifest;
- a **release notice**, displayed alongside the binary wherever it is offered. It says, in
substance, that the release includes third-party software under licences including the GNU LGPL
or the MPL, and that corresponding source and build and relink materials for the covered native
dependencies are in the accompanying artifact, named by its exact file name and SHA-256.
A reviewer can therefore tell exactly which source artifact corresponds to which redistributed
binary, from digests, without guessing from file names. The source archive is never placed inside
the Module Package. `THIRD_PARTY_NOTICES` stays as it is, and never duplicates the source.
### Public distribution fails closed
Building and testing packages stays unrestricted: the packager produces native packages exactly as
before, for development, CI, and private use. **Public redistribution approval** is a separate gate.
For a package intended for public distribution, the gate requires that:
- the package's native dependency inventory is known;
- every covered dependency has its reviewed supplement and source recipe;
- the corresponding-source artifact is present;
- every digest matches;
- the artifact's manifest validates against the recipe and the archive's own contents;
- the release notice names the exact artifact.
If any requirement fails, approval fails. Bundled (not copied) code under such a licence is not
handled by this mechanism, so the gate refuses it rather than approving it.
### Retention, and no rewriting history
- Ekklesia keeps a release's corresponding-source artifact for as long as it distributes, or
supports access to, the matching binary release. Nothing deletes one automatically while that
binary remains available.
- The bytes of a source artifact for an existing binary release are never replaced under the same
identity.
- A correction is a new release, or an explicitly versioned compliance artifact that the
redistribution metadata names. History is never silently replaced.
### What this ADR does not do
- It does not change any module's own licence, or ADR 0051's rules for it. The module's own source
obligations are met by the module's own repository, as before.
- It does not make **runtime downloading** an acceptable workaround. Fetching a copyleft library at
run time instead of redistributing it moves the obligation rather than meeting it, makes
installations depend on a third party, and breaks ADR 0048's self-contained installed modules. It
is not the preferred path.
- It does not change Module Package v1, the verifier's acceptance of a package, or the content of
existing supplements.
## Consequences
- Native modules that need copyleft native libraries, starting with sharp's libvips, can be
distributed publicly under a defined, checkable policy, instead of being blocked indefinitely.
- Each covered dependency version needs a reviewed source recipe, as it needs a reviewed supplement.
A dependency bump needs both before public distribution works again.
- Releases carry an artifact many times larger than the package itself (about 200 MB for libvips
1.3.4, against a 9 MB package), and Ekklesia commits to retaining it.
- Constructing the artifact needs the verified source cache. Acquisition happens once per recipe,
not on every build.
## Alternatives
- **Treat supplements, source URLs, or a written offer as sufficient.** Rejected: URLs and offers
depend on third parties and on time. This policy requires the source itself, retained with the
release.
- **Put the source inside the Module Package.** Rejected: installations would store hundreds of
megabytes they never need, and the package format would change for a distribution concern.
- **Download sources while constructing the artifact.** Rejected: a release would depend on mutable
remote responses. Acquisition is explicit and verified, and construction is offline.
- **Download native libraries at run time.** Rejected, as above.
- **Per-dependency special cases in code.** Rejected for the same reason as in ADR 0052: recipes are
reviewed data, and the mechanism knows no particular library.
## Deferred
- Hosting and serving source artifacts for published module releases, and the release CI that runs
the gate and keeps the verified source cache.
- Corresponding source for bundled copyleft code, if a module ever needs it.
- Recipes provided by module repositories, alongside the question ADR 0052 deferred for supplements.
@@ -0,0 +1,76 @@
# 0056: A device credential lasts while the device is in use
- Status: Accepted
- Date: 2026-10-12
- Supersedes: the "Session lifetime" section of [0036](0036-managed-device-identity-and-pairing.md).
Everything else in ADR 0036 — the device as its own principal, pairing, revocation on every
request, re-pairing as a new identity — is unchanged.
## Context
ADR 0036 gave a device credential a fixed 180 days from pairing. The intent was that a screen on a
ceiling should not need re-pairing on a short cycle, and that an abandoned screen should eventually
stop being an identity.
A fixed horizon does the second and fails the first. Every screen in the building expires on the
anniversary of its pairing whether or not it was in use the day before, and drops to a pairing code
in front of the congregation until someone with Dashboard access walks to it. For screens paired
together, that is every screen at once. Nothing about a working, unrevoked screen is less trustworthy
on day 181 than on day 179; only the calendar changed.
## Decision
**The 180 days are an idle window, not a lifetime.** A device credential is accepted while the
device was last seen less than 180 days ago (or, if it has never been seen, was paired less than 180
days ago). `lastSeenAt` already moves forward on authenticated device requests (throttled to one
write per 15 minutes), so the server-side window slides with use and needs no new column or
migration.
**The browser's copy is renewed by the host, not rotated.** The display descriptor —
`GET /api/v1/module-displays/<module>`, which every display requests when it starts, when it
reconnects, and periodically while it runs — re-issues the device cookie that authenticated the
request with a fresh 180-day `Max-Age` and the same attributes as at pairing (`HttpOnly`,
`SameSite=Strict`, host-only, `Path=/`, `Secure` on https). The value is unchanged.
Renewal happens only for a request that already resolved to a live, unrevoked device. A revoked,
expired, or unknown credential is anonymous, as before, and is never renewed; a person viewing a
display never receives a device cookie.
**Revocation remains the boundary.** It is still checked on every request, and an administrator's
revoke still takes effect immediately.
## Why not rotate
Rotation (issue a new value, invalidate the old) would bound how long any one stolen value works and
could reveal a cloned credential. For an unattended screen it trades that for a new way to strand
the display: a renewal response lost to a network drop, a power cut, or a browser that never
persisted the new cookie leaves the screen holding a dead credential and showing a pairing code,
which is the failure this decision exists to remove. A refresh-token scheme with overlap windows
could avoid that, at the cost of real complexity in the one place mistakes are invisible until a
wall goes dark. That is not justified while revocation is immediate and a device principal can only
read the displays it was paired for.
## Consequences
- A screen that is used at least once every 180 days stays paired until an administrator revokes
it. An abandoned screen still stops being an identity 180 days after it was last seen.
- Existing credentials need no migration. A screen paired under the fixed rule is renewed the next
time it requests its descriptor, provided its browser still holds the cookie.
- A stolen device credential stays usable for as long as anyone keeps using it, until it is revoked.
Under ADR 0036 it was usable for at most 180 days from pairing. The exposure is bounded by what a
device may do — the display permissions of the module it was paired for, never a person's access
— and by revocation. Operators can see each device's last-seen time and revoke anything
unexpected.
- A credential is not bound to a browser or network. Like a human session cookie, whoever presents
it is the device.
## Alternatives
- **Keep the fixed 180 days.** Rejected: every in-use screen fails on a schedule, and screens paired
together fail together.
- **Never expire.** Rejected: an abandoned or forgotten screen would remain an identity forever, and
its credential would never stop working without someone remembering to revoke it.
- **Rotate on renewal.** Rejected for now; see above.
- **Renew on every authenticated response.** Rejected: principal resolution happens deep in shared
middleware that never writes responses. One endpoint every display already requests periodically
is enough, and keeps the behaviour in one visible place.
@@ -0,0 +1,237 @@
# 0057: Dashboard URLs are real, host-owned routes
- Status: Accepted
- Date: 2026-10-14
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
[0027](0027-dashboard-navigation-and-flat-modules.md),
[0034](0034-host-owned-browser-authentication.md),
[0044](0044-self-hosted-reference-deployment.md),
[0045](0045-split-dashboard-and-api-origins.md)
## Context
The Dashboard had exactly one URL. `apps/api` served the page at `/` and nowhere else, both
reference proxies forwarded only `/` to it, and the shell kept "which module is open" in React state.
Opening a module never changed the address bar, so a refresh always returned to the first module,
nothing could be bookmarked or shared, and the browser's Back button left the Dashboard entirely.
ADR 0026 rejected a client router *for that pass* — "a selected-module state is sufficient" — and
ADR 0027 deferred client routing and deep links.
The product contract now requires them. DESIGN.md §2.7 says the URL represents the current place in
the product: a refresh returns to the same page, deep links work, Back and Forward behave
predictably. §5 makes an **Overview** the Dashboard's landing page, and §12.6 says a disabled module's
routes stop behaving as active routes. Everything else Stage 1 builds — the sidebar, Settings, the
account area, platform Displays, a module's own pages in the sidebar, and returning to the workflow a
Settings deep link came from — needs stable addresses to hang from, so this is the first slice.
A route that works in the shell but not on refresh is worse than no route, because it looks like it
works. Refresh is a server and proxy concern before it is a browser one: the API had to serve the
page at the new paths, and the proxies had to forward them.
## Decision
### One top-level URL space, shared by platform pages and module pages
```text
/ the Overview
/settings[/…] Settings
/account[/…] the account area
/<module-id> a module's page: /signage, /calendar, /people
/<module-id>/<page>[/…] reserved for that module's own pages: /signage/timelines
```
A module is a first-class top-level destination, addressed by its id, exactly as Settings and the
Account area are. "Module" is an implementation concept — how the platform packages a product — and
it does not appear in the addresses people see, bookmark, and share. A module id is lowercase
kebab-case and Core-validated, which is what makes it usable as a path segment with no further
encoding.
### Platform-owned top-level names are reserved, and Core enforces it
Sharing one namespace means a module must never be able to take a name the platform uses.
`RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`) is the complete set the platform serves
at the top level **today**, taken from what is actually registered rather than from guesses:
| Name | Owned by |
| --- | --- |
| `api` | the API: identity, principal, health, Core resources, module routes, module UI artifacts, display descriptors, realtime |
| `assets` | the Dashboard's and display host's scripts |
| `display` | the display host's pages and service worker |
| `settings` | the Dashboard's Settings pages |
| `account` | the Dashboard's account area |
Discovery refuses a module that declares one (`MODULE_ID_RESERVED`), so the check covers API
startup, `pnpm modules:validate`, and Module Package verification — before a module is installed,
not only when it is served. Exact names only: `settings-sync` or `displays` are ordinary ids.
The set is held to reality by tests in `apps/api` rather than by review: every top-level segment the
composed API registers is a reserved name or a module page, the display host's and Dashboard
script's path constants all start with a reserved name, and every Dashboard page the shell
interprets is reserved. Adding a new top-level platform route therefore fails a test until its name
is reserved. Reserving a name later is a **breaking change** for any installation with a module of
that id, so the set grows only deliberately, and only for a real route.
The rule lives in Core's discovery rather than in the module manifest grammar
(`@ekklesia/contracts`), because it is the Ekklesia host's URL space, not a property of a module: a
standalone host (ADR 0021) has no Settings page to protect. A module built against the current
public packages is unaffected unless it uses one of these five names.
### The server serves one page, unchanged, at every real Dashboard address — and nowhere else
`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at `/settings` and
`/account` and everything below each (`DASHBOARD_PLATFORM_PAGES`, exported by the shell as
`@ekklesia/shell/dashboard-routes`, so the server and the shell's router read one list), and at
`/<module-id>` and everything below it for **each module this process discovered that declares a
Dashboard UI**. Every answer is the same frozen bytes, the same strong `ETag`, and the same security
headers as `/`: the path selects a page inside the shell, never a file on the server. Nothing from
the request is composed into a lookup, no directory is exposed, and the script and source map remain
reachable only at their own literal paths.
The module set is the boot snapshot's discovered inventory, fixed at startup like every other
module-derived route, and it deliberately **includes disabled and blocked modules**. A bookmark to a
module an administrator turned off still opens the Dashboard, which says the module is unavailable;
that is more useful than an address that suddenly looks like it never existed. A module that is not
installed, or that has no Dashboard UI (a display-only module, ADR 0033), has no page.
Everything else stays the sanitized `404`: an arbitrary root path (`/definitely-not-a-real-module`),
a look-alike (`/settings-old`, `/signages`), another method, `/index.html`, and so on. The routes
also refuse to compose if handed a module page that is not a valid module id, is a reserved name, or
appears twice — discovery already guarantees all three, and the routes do not trust it.
Serving `/<module-id>` before sign-in means an anonymous request can learn whether a module with a
Dashboard UI is installed, by its `200` or `404`. That is accepted: it is the same disclosure the
display host already makes at `/display/<module-id>` (ADR 0033), a module id names a product rather
than any data, and the alternative — a page at every module-shaped root path — is exactly the
"arbitrary path becomes a Dashboard page" this decision rejects. The authenticated module inventory
and every module route keep their authorization unchanged.
### The proxies forward module-shaped names and let the API decide
A proxy cannot know which modules an installation has, and a proxy configuration listing them would
have to be edited on every install. Both reference Caddyfiles therefore forward every top-level path
whose first segment has a module id's shape, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$`, and the API
answers it with the page or its sanitized `404`. Anything not shaped like one — `/favicon.ico`,
`/.well-known/…`, an uppercase name — is still refused by the proxy without reaching the API. In the
split topology (ADR 0045) the Dashboard hostname forwards module-shaped names but still refuses
`/api`, `/display`, and `/assets`, and the API hostname still serves no Dashboard page. The
development server falls back to the page for any path with no file.
### The shell's router is host-owned, small, and deliberately not a library
The address bar is the source of truth for which page is open. The shell reads it through a
four-method `HostHistory` (`pathname`, `push`, `replace`, `subscribe`); the browser implementation is
built in the entry point from `window` and handed down, so the entry point remains the only file in
the package that touches a browser global, and every routing behaviour is tested against an in-memory
history whose entries a test can inspect. No state object is ever stored in a history entry — the path
alone is meaningful — so nothing put into history can be read back as authority.
`react-router-dom` is not adopted. Modules are refused it because routing belongs to the host
(ADR 0022, `pnpm boundaries`); the shell's own pages are a handful of host screens plus one mounted
module; and adopting a library now would prejudge how a module's own pages are addressed, which is
a separate decision (see **Deferred**).
### What each path shows
- `/` is the **Overview**. The shell no longer opens the first module for the person: the Overview is
the landing page. Until modules can contribute cards to it, it says plainly that it has nothing to
show yet.
- `/<module-id>` mounts the module whose navigation entry has that id, through the unchanged generic
loader and the unchanged keyed frame, so a stale load still cannot reach a newer page. The server's
navigation is the only authority on what is mountable. An id it does not list — a disabled or
blocked module whose address the server still serves — shows *This page isn't available* and loads
nothing; the page never echoes the address back. This is what makes a disabled module's routes stop
behaving as active routes, with no new server state.
- `/settings…` and `/account…` are the platform's own pages and are never read as module pages. They
have no content yet, so they show *Page not found* for now.
- Everything below `/<module-id>/` belongs to that module's own pages. It is served by the API today
and shows *Page not found* in the shell until the module page contract exists. The shell never
guesses, and never shows a module's first page for an address the module never defined.
- One trailing slash is tolerated and corrected to the canonical spelling in place (`replace`, no new
entry), so what is bookmarked next is the one canonical address.
### Navigation is real links
Every navigation item is an `<a href>` with its canonical address and `aria-current="page"` when it
is open, marked by weight as well as colour. An ordinary click opens the page in place and adds one
history entry; a click with a modifier key or the middle button is left to the browser, so a page can
be opened in a new tab. Clicking the open page's own link adds no entry. The browser's Back and Forward
walk exactly the pages that were open.
### Page changes behave like page loads for assistive technology
After an in-place page change the shell moves focus to the page's `<main>` (named after the page) as
a full load would have; the first page shown after a refresh or sign-in is left alone. The entry point
names the browser tab after the open page ("Overview", the module's display name, or the error page's
title, and "Sign in" while signed out) — never "Ekklesia", in keeping with ADR 0046. The static page's
own `<title>` is unchanged here; it belongs to the branding work.
### Sign-in is unchanged, and still has no return URL
ADR 0034 holds exactly. The sign-in form still replaces the application in place at the current URL,
so a deep link opened while signed out lands on its own page once the person signs in — with no
`return`, `next`, or callback value, no redirect, and no script-driven navigation off the
Dashboard's own paths. The shell only ever moves between its own canonical paths. Emailed
verification and reset links still land on `/` with a query (ADR 0038).
## Alternatives
- **Module pages under a prefix (`/modules/<id>`).** Rejected: it exposes an implementation concept
in every address people see, and makes a module a second-class destination beside Settings. The
collision it avoided is handled instead by reserving the platform's names in Core.
- **Hash routing (`/#/signage`).** Rejected: every address is one page to the server, the proxy, and
any analytics; fragments never reach the server, so no server-side decision about a path is
possible; and DESIGN.md asks for real URLs.
- **Serving the page at any module-shaped root path.** Rejected: `/definitely-not-a-real-module`
would become a Dashboard page, and the server would answer `200` for every mistyped address. It
would remove the installed-module disclosure described above, at the cost of the server no longer
meaning anything by a `200`.
- **Serving only enabled, running modules' pages.** Rejected: a bookmark to a module an
administrator disabled would become a bare `404`, indistinguishable from a typo, instead of the
Dashboard explaining that the module is unavailable.
- **Listing modules in the proxy configuration.** Rejected: every install would need a proxy edit.
- **Reserving more names now** (`login`, `help`, `overview`, …) "just in case". Rejected: a reserved
name refuses a real module, so a name is reserved when the platform gets a route there and not
before.
- **Putting the reserved names in the manifest grammar.** Rejected for now: it is the Ekklesia host's
URL space, not a module property, and would need a public-contract release for no extra safety,
since discovery already refuses the module before it can be installed.
- **`react-router-dom` or another routing library.** Rejected for now; see above.
- **Keeping "open the first module" at `/`.** Rejected: DESIGN.md makes the Overview the landing page,
and an address that silently means "whatever happens to be first" is not a stable address.
## Consequences
- A refresh, a bookmark, and a shared link land on the page that was open; Back and Forward walk the
Dashboard's own pages. Proven at three levels: the shell against an in-memory history; `apps/api`
serving the page at platform pages and installed modules' pages and the sanitized `404` everywhere
else; and the deploy smoke test, through the real proxy, checking platform deep links, unknown
names, a module's address staying served while the module is disabled and disappearing and
returning as it is removed and reinstalled,
and reloading a module's address in a real browser and requiring the same module to mount again.
- **The Dashboard opens on the Overview, not on the first module.** A person who used to land in
Signage now chooses it once from the navigation, and can bookmark `/signage`.
- **A module may not be called `account`, `api`, `assets`, `display`, or `settings`.** No known module
is; one that was would now fail discovery with `MODULE_ID_RESERVED` and must be renamed.
- **Operators with their own proxy must forward module-shaped top-level paths** (the matcher above),
or a refresh on any page other than `/` is the proxy's `404`. The deployment guide says so.
- `@ekklesia/shell` gains a `./dashboard-routes` subpath, and `apps/api` reads it. The surface remains
private; this is the same relationship `./dashboard-assets` already has.
- The navigation landmark is labelled "Dashboard" rather than "Modules", because it now contains the
Overview as well as modules.
## Deferred
Each of these needs its own decision. This ADR reserves the addresses for them and decides none of
them:
- **A module's own pages** — how a module declares the pages that appear under it in the sidebar
(`/signage/playback`, `/signage/timelines`, `/signage/displays`), how it learns and changes its
current sub-path through the module UI SDK, and what an address below `/<module-id>/` means. The
server already serves the page there and the proxies already forward it, so that contract is a
manifest, SDK, and shell change with no further routing or deployment change. It is its own ADR.
- **Return to origin** — how a module or a Settings page links into another part of the Dashboard and
comes back to the exact workflow that sent the person there (DESIGN.md §2.7, §4.8). It must remain
a validated, same-origin route decision, as ADR 0034 requires, and is designed with the module page
contract rather than here.
- The Settings and Account pages themselves, the sidebar redesign, the mobile drawer, Overview cards,
breadcrumbs, and the installation's name in the browser tab.

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