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
100 changed files with 18576 additions and 325 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ concurrency:
env: env:
# The only registry-specific setting: where the train is published. # The only registry-specific setting: where the train is published.
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
jobs: jobs:
package-release: package-release:
+64 -15
View File
@@ -6,27 +6,60 @@ contents, because restated details drift.
## What this repository is ## What this repository is
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
monorepo: 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 - **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
feature module, a surface, or an app. imports a feature module, a surface, or an app.
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product - **Modules** are independently versioned product packages. First-party modules live in **their own
packages. They own their own data and migrations, declare capabilities/permissions through repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a (ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
primary and full-viewport display artifacts, authored in React against the same role-neutral example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source. `@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 - **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 presentation concern a module does not. The shell **hosts** module UI through the generic loader;
or module internals directly. 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 These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
documented. Read `docs/architecture/repository-boundaries.md` and documented. Read `docs/architecture/repository-boundaries.md` and
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer. `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 ## ADRs are authoritative
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are `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/decisions/README.md` and every ADR relevant to your change
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md` - `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
- `README.md` (current platform status — what is and is not implemented) - `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` - `docs/development/repository-workflow.md` and `docs/development/testing.md`
- `CONTRIBUTING.md` - `CONTRIBUTING.md`
@@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
## Working conventions ## Working conventions
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never - The canonical repositories are on the church Gitea under the `Zayin` organization:
change branch-protection settings. `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 - Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
burying them in an implementation. burying them in an implementation.
- Add or update tests when you change a contract or architectural rule. New behavior needs new - 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 - 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 explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
an explicit, operator-invoked action. 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 ## Canonical verification commands
+19 -2
View File
@@ -59,7 +59,10 @@ supplied `LICENSE` bytes; dependency material remains separate in `THIRD_PARTY_N
packages may bundle a module-owned Drizzle version without coupling it to Core; 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 legacy-v8 packages retain their transitional exact-version check. Where a redistributed dependency
omits licence material, the packager may add reviewed licence supplements omits licence material, the packager may add reviewed licence supplements
([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). Purge, other ([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. 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 A module can be built in its own repository from Ekklesia's public module-development packages
@@ -819,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
public Website remain separate future designs. public Website remain separate future designs.
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested **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 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 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 deployment for the built shells, general sidecar asset publication, React runtime sharing, the
@@ -902,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 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. 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 ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/api", "name": "@ekklesia/api",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
+19 -5
View File
@@ -31,7 +31,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
import type { DashboardHostAssets } from "./dashboard-host-assets.js"; import type { DashboardHostAssets } from "./dashboard-host-assets.js";
import { createDashboardHostRoutes } from "./dashboard-host-routes.js"; import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
import { createDashboardRoutes } from "./dashboard-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js";
import { createDeviceRoutes } from "./device-routes.js"; import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js"; import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js"; import { createGrantsRoutes } from "./grants-routes.js";
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js"; import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
@@ -363,10 +363,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
// own entirely. See `./health-routes.js`. // own entirely. See `./health-routes.js`.
app.route("/", createHealthRoutes(options.health)); app.route("/", createHealthRoutes(options.health));
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They // The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See // page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
// `./dashboard-host-routes.js`. // `/<module-id>`. They grant no authority and resolve no principal; the page asks
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets)); // `/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( app.route(
"/", "/",
@@ -407,6 +415,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
registry: options.authorization.registry, registry: options.authorization.registry,
runtime, runtime,
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}), ...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
...(options.devices
? {
renewDeviceCredential: (c: Context) =>
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
}
: {}),
}); });
if (options.devices) { 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 { Hono } from "hono";
import { etagMatches } from "./conditional-request.js"; import { etagMatches } from "./conditional-request.js";
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.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 * The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed * serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never * this server and the shell's router read one list), and at each installed module's `/<module-id>`.
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under * Every one of those first segments is either a reserved platform name or a module id Core accepted,
* `/api/v1/modules/<module-id>`. * 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_PAGE_PATH = "/";
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js"; 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 * Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
* default same-origin shape. * default same-origin shape.
* *
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file * Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`, * from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary * platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders * module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
* the sign-in form until `/api/v1/principal` says otherwise. * 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 snapshot = snapshotAssets(assets);
const modulePages = validatedModulePages(options.modulePages);
const routes = new Hono(); const routes = new Hono();
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => const page = (ifNoneMatch: string | undefined): Response =>
fixedAsset( fixedAsset(
c.req.header("if-none-match"), ifNoneMatch,
snapshot.html, snapshot.html,
snapshot.htmlEtag, snapshot.htmlEtag,
"text/html; charset=utf-8", "text/html; charset=utf-8",
DASHBOARD_PAGE_SECURITY_HEADERS, 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) => routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
fixedAsset( fixedAsset(
c.req.header("if-none-match"), c.req.header("if-none-match"),
+22 -1
View File
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
*/ */
function writeDeviceCookie( function writeDeviceCookie(
c: Context, c: Context,
options: DeviceRoutesOptions, options: Pick<DeviceRoutesOptions, "secureCookies">,
name: string, name: string,
value: string, value: string,
maxAgeSeconds: number, 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 { export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
const routes = new HonoApp(); const routes = new HonoApp();
const now = options.now ?? ((): Date => new Date()); 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 /** Omitted only when the registry contains no display entrypoints. Production reads the exact
* surface-owned files before binding and supplies their frozen contents here. */ * surface-owned files before binding and supplies their frozen contents here. */
readonly assets?: DisplayHostAssets; 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, /** A composition error, detected before any display route is registered. Carries no module id,
@@ -199,6 +202,11 @@ function mountDisplay(
if (!viewer) { if (!viewer) {
return displayFailure(c); 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); return c.json(await describeDisplay(options, principal, display, viewer), 200);
} catch { } catch {
return displayFailure(c); return displayFailure(c);
+26 -3
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 }> { async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label); const name = makeDatabaseName(label);
await createDatabase(name); await createDatabase(name);
@@ -1349,7 +1372,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
await running.shutdown(); await running.shutdown();
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy(); 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); expect(remainingConnections).toBe(0);
await dropDatabase(database.name); 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 () => { it("shuts down cleanly, closing the one shared pool", async () => {
await running.shutdown(); await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name); const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0); expect(remainingConnections).toBe(0);
await dropDatabase(database.name); await dropDatabase(database.name);
}); });
@@ -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 () => { 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(); await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name); const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0); expect(remainingConnections).toBe(0);
// Exactly one stop, matching the exactly-one register/start already asserted above. // Exactly one stop, matching the exactly-one register/start already asserted above.
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1); expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
+217 -3
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
type ModuleRouteHandler, type ModuleRouteHandler,
parseModuleManifest,
parsePermissionId, parsePermissionId,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
@@ -13,9 +14,13 @@ import {
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
type DiscoveredModule,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
isReservedModuleId,
type LegacyModuleRuntimeServices, type LegacyModuleRuntimeServices,
type ModuleDatabase, type ModuleDatabase,
type ModuleLifecycleConfiguration,
RESERVED_MODULE_IDS,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -33,11 +38,23 @@ import {
DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_SCRIPT_PATH, DASHBOARD_HOST_SCRIPT_PATH,
DashboardHostConfigurationError, DashboardHostConfigurationError,
createDashboardHostRoutes,
} from "../src/dashboard-host-routes.js"; } 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -78,9 +95,40 @@ function fakeModuleServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); 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( function buildApp(
options: { options: {
readonly assets?: DashboardHostAssets; readonly assets?: DashboardHostAssets;
readonly configuration?: ModuleLifecycleConfiguration;
readonly discovered?: readonly DiscoveredModule[];
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[]; readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
} = {}, } = {},
) { ) {
@@ -100,7 +148,10 @@ function buildApp(
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
moduleUi: createModuleUiPublicationRegistry([]), moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), 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)", () => { 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 () => { it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
const { app } = buildApp(); const { app } = buildApp();
@@ -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 () => { it("keeps a human administrator's own session unaffected by a device cookie", async () => {
const running = await platform("mixed"); const running = await platform("mixed");
const device = new DeviceBrowser(running.origin); const device = new DeviceBrowser(running.origin);
+24 -1
View File
@@ -195,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 }> { async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label); const name = makeDatabaseName(label);
await createDatabase(name); await createDatabase(name);
@@ -760,7 +783,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
await running.shutdown(); await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name); const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0); expect(remainingConnections).toBe(0);
const eventsAfterShutdown = readRecorder(); const eventsAfterShutdown = readRecorder();
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/core", "name": "@ekklesia/core",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
+13
View File
@@ -25,6 +25,7 @@ class VerifyArgumentsError extends Error {}
const USAGE = const USAGE =
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]"; "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 { export interface VerifyModulePackageArguments {
readonly archivePath: string; readonly archivePath: string;
@@ -90,6 +91,16 @@ export function parseSha256File(contents: string): string {
return match[1].toLowerCase(); 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>> { async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>; return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
} }
@@ -121,6 +132,7 @@ export async function readModulePackagePlatform(
async function main(): Promise<void> { async function main(): Promise<void> {
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2)); const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
let expectedSha256 = parsed.expectedSha256; let expectedSha256 = parsed.expectedSha256;
if (parsed.sha256File !== undefined) { if (parsed.sha256File !== undefined) {
let contents: string; let contents: string;
@@ -137,6 +149,7 @@ async function main(): Promise<void> {
const result = await verifyModulePackage({ const result = await verifyModulePackage({
archivePath: parsed.archivePath, archivePath: parsed.archivePath,
platform, platform,
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
...(expectedSha256 === undefined ? {} : { expectedSha256 }), ...(expectedSha256 === undefined ? {} : { expectedSha256 }),
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }), ...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
}); });
+18 -8
View File
@@ -44,17 +44,26 @@ export class DeviceStoreError extends Error {
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000; 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 * The window slides: every request from the device moves `lastSeenAt` forward, and the host
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen * re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server * administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
* decides on every single request whether the device is still revoked, so an administrator's revoke * on every single request. The window exists only so an abandoned, forgotten screen eventually stops
* takes effect immediately regardless of how long the credential would otherwise have lived. The * being an identity at all: 180 days after it was last seen, its credential is dead.
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
*/ */
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000; 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 /** 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. */ * realtime and assets would otherwise turn an operational nicety into a continuous write load. */
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000; 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 // The credential's own horizon, measured from when the device was paired. Revocation is
// checked above and is what makes a long horizon acceptable. // 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 undefined;
} }
return Object.freeze({ return Object.freeze({
+14
View File
@@ -9,6 +9,7 @@ import {
safeParseModuleManifest, safeParseModuleManifest,
semanticVersionSchema, semanticVersionSchema,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { isReservedModuleId } from "./reserved-ids.js";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json"; export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
@@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode =
| "MODULE_ENTRYPOINT_INVALID" | "MODULE_ENTRYPOINT_INVALID"
| "MODULE_ENTRYPOINT_MISSING" | "MODULE_ENTRYPOINT_MISSING"
| "MODULE_ID_DUPLICATE" | "MODULE_ID_DUPLICATE"
| "MODULE_ID_RESERVED"
| "MODULE_MANIFEST_INVALID" | "MODULE_MANIFEST_INVALID"
| "MODULE_MANIFEST_MISSING" | "MODULE_MANIFEST_MISSING"
| "MODULE_PACKAGE_INVALID" | "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); const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!artifacts) { if (!artifacts) {
return undefined; return undefined;
+1
View File
@@ -13,6 +13,7 @@ export * from "./module-package-host.js";
export * from "./module-package-verification.js"; export * from "./module-package-verification.js";
export * from "./permissions.js"; export * from "./permissions.js";
export * from "./realtime.js"; export * from "./realtime.js";
export * from "./reserved-ids.js";
export * from "./runtime.js"; export * from "./runtime.js";
export * from "./schema-discovery.js"; export * from "./schema-discovery.js";
export * from "./ui-publication.js"; export * from "./ui-publication.js";
@@ -1,6 +1,6 @@
import { fork } from "node:child_process"; import { fork } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -73,7 +73,8 @@ export type ModulePackageVerificationErrorCode =
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID" | "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
| "MODULE_PACKAGE_SDK_INCOMPATIBLE" | "MODULE_PACKAGE_SDK_INCOMPATIBLE"
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE" | "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"; | "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE: MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
@@ -118,6 +119,8 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.", MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED: MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
"The package metadata contains transitional facts this platform does not understand.", "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_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_MODE: "The archive contains a setuid, setgid, or sticky entry.",
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.", MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
@@ -157,6 +160,12 @@ export interface VerifyModulePackageOptions {
readonly platform: ModulePackagePlatform; readonly platform: ModulePackagePlatform;
/** A directory that must not exist yet; the verified module directory is written there. */ /** A directory that must not exist yet; the verified module directory is written there. */
readonly extractTo?: string; 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; readonly loadTimeoutMs?: number;
} }
@@ -414,12 +423,66 @@ function probeEntrypointPath(): string {
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`); 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 /** 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. */ * only whether each loaded and exported a definition naming this module. */
async function loadEntrypoints( async function loadEntrypoints(
manifest: Pick<ModuleManifest, "database" | "id" | "version">, manifest: Pick<ModuleManifest, "database" | "id" | "version">,
entrypoints: Readonly<Record<string, string>>, entrypoints: Readonly<Record<string, string>>,
timeoutMs: number, timeoutMs: number,
executionDirectory: string,
): Promise<void> { ): Promise<void> {
const outcome = await new Promise<"ok" | "failed">((resolve) => { const outcome = await new Promise<"ok" | "failed">((resolve) => {
let settled = false; let settled = false;
@@ -445,7 +508,7 @@ async function loadEntrypoints(
}), }),
], ],
{ {
cwd: tmpdir(), cwd: executionDirectory,
env: {}, env: {},
execArgv: [], execArgv: [],
serialization: "json", serialization: "json",
@@ -514,7 +577,7 @@ export async function verifyModulePackage(
const manifest = checkIdentity(archive, metadata, files); const manifest = checkIdentity(archive, metadata, files);
checkCompatibility(metadata, manifest, files, options.platform); checkCompatibility(metadata, manifest, files, options.platform);
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-")); const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
try { try {
// The candidate alone, in a scratch inventory of its own: never module storage. // The candidate alone, in a scratch inventory of its own: never module storage.
const modulesDirectory = path.join(scratch, "candidates"); const modulesDirectory = path.join(scratch, "candidates");
@@ -550,7 +613,7 @@ export async function verifyModulePackage(
// Executed only now, and first in isolation: a schema is imported in this process below only // 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. // after the probe proved it loads on its own.
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000); await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
try { try {
await discoverModuleSchemas(discoveryOptions); await discoverModuleSchemas(discoveryOptions);
} catch (error) { } catch (error) {
+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);
}
+48
View File
@@ -4,8 +4,10 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { import {
discoverModules, discoverModules,
isReservedModuleId,
ModuleDiscoveryError, ModuleDiscoveryError,
type ModuleDiscoveryIssueCode, type ModuleDiscoveryIssueCode,
RESERVED_MODULE_IDS,
} from "../src/modules/index.js"; } from "../src/modules/index.js";
interface ModuleFixtureOptions { interface ModuleFixtureOptions {
@@ -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 () => { it("enforces platform and Module SDK compatibility", async () => {
const modulesDirectory = await createModulesDirectory(); const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "future", { await createModule(modulesDirectory, "future", {
+53 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
@@ -48,6 +48,12 @@ async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
return { archivePath, sha256: fixture.sha256 }; 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( async function rejection(
options: PackageFixtureOptions, options: PackageFixtureOptions,
code: ModulePackageVerificationErrorCode, code: ModulePackageVerificationErrorCode,
@@ -100,6 +106,52 @@ describe("Module Package v1 verification (ADR 0049)", () => {
}); });
}); });
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 () => { it("extracts only a verified package, with normalized modes, into a new directory", async () => {
const { archivePath } = await archiveFile(); const { archivePath } = await archiveFile();
const target = path.join(await scratch(), "example"); const target = path.join(await scratch(), "example");
+12
View File
@@ -1,5 +1,6 @@
import { import {
EMPTY_MODULE_STORAGE, EMPTY_MODULE_STORAGE,
isModuleStorageError,
isValidModuleStorageKey, isValidModuleStorageKey,
MODULE_STORAGE_KEY_MAX_BYTES, MODULE_STORAGE_KEY_MAX_BYTES,
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS, 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 () => { it("gives a module no way to reach another module's storage by knowing its name", async () => {
const shared = fakeProvider(); const shared = fakeProvider();
const announcements = createModuleStorage({ const announcements = createModuleStorage({
@@ -3,6 +3,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
MODULE_VERIFIER_SCRATCH_ENV,
moduleVerifierScratchDirectory,
parseSha256File, parseSha256File,
parseVerifyModulePackageArguments, parseVerifyModulePackageArguments,
readModulePackagePlatform, readModulePackagePlatform,
@@ -57,6 +59,21 @@ describe("verify-module-package digest files", () => {
}); });
}); });
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", () => { describe("verify-module-package platform facts", () => {
it("reads this platform's versions and its transitional database runtime from the repository", async () => { 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 root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
+14 -1
View File
@@ -42,9 +42,16 @@
{$EKKLESIA_HOSTNAME} { {$EKKLESIA_HOSTNAME} {
# Everything the API serves: the Dashboard at /, the API, display pages and their service # 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/* @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. # Module realtime is Server-Sent Events: flush every event immediately.
@realtime path /api/v1/module-realtime/* @realtime path /api/v1/module-realtime/*
@@ -63,6 +70,12 @@
} }
} }
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle { handle {
respond 404 respond 404
} }
+16
View File
@@ -51,12 +51,28 @@
{$EKKLESIA_DASHBOARD_HOSTNAME} { {$EKKLESIA_DASHBOARD_HOSTNAME} {
@dashboard path / /assets/ekklesia-dashboard/* @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 { handle @dashboard {
reverse_proxy api:3000 { reverse_proxy api:3000 {
import ekklesia_forwarding import ekklesia_forwarding
} }
} }
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle { handle {
respond 404 respond 404
} }
+30 -6
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/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. | | [`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)). | | [`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 ## The reference deployment
@@ -170,9 +171,20 @@ identity, target, and Node.js range against this platform, runs the platform's m
validation, and loads the backend once in a short-lived process with no environment. Only then does 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: 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 ```sh
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
docker run --rm --network none --read-only --tmpfs /tmp \ 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" \ -v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \ --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 \ /package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
@@ -229,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`. `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`. 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 ### Rolling back
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
@@ -251,7 +271,7 @@ The same image serves both; only configuration differs.
| Hostname | Serves | Refuses (`404`) | | 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 `/` | | API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | | | Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
@@ -327,7 +347,11 @@ networks:
Then make your proxy do what `Caddyfile.example` does: 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 - **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events); - stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
@@ -393,7 +417,7 @@ deployment:
| # | Item | Where it is met | | # | 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. | | 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. | | 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`. | | 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
@@ -500,10 +524,10 @@ finds its files:
```sh ```sh
docker build \ docker build \
--file deploy/Dockerfile \ --file deploy/Dockerfile \
--build-arg VERSION=0.2.1 \ --build-arg VERSION=0.2.5 \
--build-arg REVISION="$(git rev-parse HEAD)" \ --build-arg REVISION="$(git rev-parse HEAD)" \
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \ --build-arg SOURCE=https://git.example.org/your-org/ekklesia \
--tag ekklesia:0.2.1 \ --tag ekklesia:0.2.5 \
. .
``` ```
+4
View File
@@ -132,6 +132,10 @@ services:
command: command:
- mini - mini
- -dir=/data - -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.port.https=8443
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt - -s3.cert.file=/run/ekklesia/tls/object-storage.crt
- -s3.key.file=/run/ekklesia/tls/object-storage.key - -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. 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 - **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. 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 - **`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. 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 - **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 ## 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 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, administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or 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 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. 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 ### Cookie and origin policy does not broaden for the Dashboard
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax, The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
@@ -2,6 +2,7 @@
- Status: Accepted - Status: Accepted
- Date: 2026-08-24 - 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), - Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
[0032](0032-native-module-realtime-transport.md), [0032](0032-native-module-realtime-transport.md),
[0033](0033-display-kiosk-host-contract.md), [0033](0033-display-kiosk-host-contract.md),
@@ -1,6 +1,6 @@
# 0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material # 0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material
- Status: Proposed - Status: Accepted
- Date: 2026-10-07 - Date: 2026-10-07
- Extends: [0049](0049-module-package-v1.md), in one respect only. ADR 0049 left exactly how licence - 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 and notice material is collected to the packager's implementation. That implementation, and the
@@ -9,7 +9,9 @@
further source: a reviewed supplement. Everything else in ADR 0049 is unchanged, as is further source: a reviewed supplement. Everything else in ADR 0049 is unchanged, as is
`packageFormat`. `packageFormat`.
- Related: [ADR 0048](0048-generic-image-and-installed-module-storage.md), - Related: [ADR 0048](0048-generic-image-and-installed-module-storage.md),
[ADR 0051](0051-module-package-license-material.md) [ADR 0051](0051-module-package-license-material.md),
[ADR 0055](0055-corresponding-source-artifacts.md) (decides the source-obligation policy deferred
below)
## Context ## Context
@@ -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.
+5 -2
View File
@@ -44,7 +44,7 @@ ADRs capture durable choices that affect more than one package or phase.
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted - [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted - [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted - [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted - [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted (session lifetime superseded by 0056)
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted - [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted - [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted - [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
@@ -60,6 +60,9 @@ ADRs capture durable choices that affect more than one package or phase.
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted - [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
- [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted - [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted
- [0051: Module packages declare their own license and carry its text](0051-module-package-license-material.md) — Accepted - [0051: Module packages declare their own license and carry its text](0051-module-package-license-material.md) — Accepted
- [0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material](0052-reviewed-license-supplements.md) — Proposed - [0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material](0052-reviewed-license-supplements.md) — Accepted
- [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted - [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted
- [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted - [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
- [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted
+13 -2
View File
@@ -343,7 +343,9 @@ permission the inventory does; organizing requires the new platform permission
shell renders that arrangement and computes none of it. shell renders that arrangement and computes none of it.
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no **Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment Dashboard Home or widgets, no design system, and no client routing (client routing and an Overview
landing page have since landed; see
[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md)). A production CSP and deployment
for the built shell, general asset publication, and the standalone host remain future work. Phase 7B for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
pass 1 later supplies the host-owned sign-in flow described below. pass 1 later supplies the host-owned sign-in flow described below.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
@@ -692,10 +694,19 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
reviewed supplements checked in with the packager add it reviewed supplements checked in with the packager add it
([ADR 0052](decisions/0052-reviewed-license-supplements.md)). Each supplement matches one exact ([ADR 0052](decisions/0052-reviewed-license-supplements.md)). Each supplement matches one exact
package name, version, and content digest; anything else fails closed. package name, version, and content digest; anything else fails closed.
- **Corresponding source.** A native package that redistributes code under the LGPL, the MPL, or
a similar licence is approved for public distribution only together with a sibling
corresponding-source artifact. The artifact is built offline from a reviewed source recipe and a
verified source cache, and linked to the package by digest
([ADR 0055](decisions/0055-corresponding-source-artifacts.md)). Building and testing packages is
unaffected.
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links, - **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
special files, and escaping paths. It checks every digest and identity, the target and runtime, special files, and escaping paths. It checks every digest and identity, the target and runtime,
runs the platform's own module, schema, and migration validation, and loads the backend once in a runs the platform's own module, schema, and migration validation, and loads the backend once in a
short-lived process with an empty environment. Only then does it extract anything. short-lived process with an empty environment. Only then does it copy the package to a requested
output directory. In the hardened container invocation, `/tmp` remains `noexec`; the temporary
candidate lives on a dedicated, bounded executable tmpfs because a native addon's code must be
mapped executable to load.
- **A native package's host checks.** It is accepted only on a host whose operating system, CPU, - **A native package's host checks.** It is accepted only on a host whose operating system, CPU,
C library family and version, and Node-API version Node.js itself reports as compatible. C library family and version, and Node-API version Node.js itself reports as compatible.
- **ELF files.** Every ELF file in it must be built for the target's machine. - **ELF files.** Every ELF file in it must be built for the target's machine.
+10
View File
@@ -259,6 +259,16 @@ redirect target and performs no post-login navigation, so external and protocol-
have no redirect sink in this pass. See have no redirect sink in this pass. See
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md). [ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
Since [ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md) that URL
is a real page address: the API serves the Dashboard page at `/`, at `/settings` and `/account`, and
at `/<module-id>` for every discovered module that declares a Dashboard UI (enabled or not) — each
bare and with everything below it — with the same bytes, validator, and headers as `/`. A deep link
opened while signed out therefore shows the sign-in form at that address and opens its own page once
the person signs in — still with no return value and no redirect. Any other path — a module that is
not installed, a display-only module, a look-alike such as `/settings-old` — remains the sanitized
`404`. Module ids `account`, `api`, `assets`, `display`, and `settings` are reserved for the platform
and refused by discovery (`MODULE_ID_RESERVED`).
### The four anonymous screens ### The four anonymous screens
The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in, The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
+6 -6
View File
@@ -207,10 +207,10 @@ Two builds of the same commit produce byte-identical tarballs, and CI proves it
## Publishing the train ## Publishing the train
The registry is the Gitea npm registry under the `River_of_Glory_Church` owner: The registry is the Gitea npm registry under the `Zayin` owner:
```text ```text
https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ https://git.zayin.net/api/packages/Zayin/npm/
``` ```
| Who | May | | Who | May |
@@ -272,7 +272,7 @@ Evidence that one train was published, not a second package catalog, and never a
{ {
"publicationRecord": 1, "publicationRecord": 1,
"packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" }, "packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" },
"registry": "https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/", "registry": "https://git.zayin.net/api/packages/Zayin/npm/",
"finalTag": "latest", "finalTag": "latest",
"completedAt": "<ISO time>", "completedAt": "<ISO time>",
"packages": [ "packages": [
@@ -297,7 +297,7 @@ Evidence that one train was published, not a second package catalog, and never a
- **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by - **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by
the publish step. Nobody needs a token to read or install. the publish step. Nobody needs a token to read or install.
- **The publisher** is a dedicated account, member only of a `River_of_Glory_Church` team whose one - **The publisher** is a dedicated account, member only of a `Zayin` team whose one
permission is Packages: Write. Its token has only the `write:package` scope. Gitea's permission is Packages: Write. Its token has only the `write:package` scope. Gitea's
`write:package` also allows deleting packages; the tool never does, and nothing else uses the `write:package` also allows deleting packages; the tool never does, and nothing else uses the
token. token.
@@ -319,7 +319,7 @@ guarantee:
- **A published version is never deleted,** by anyone, for any reason short of a security incident. - **A published version is never deleted,** by anyone, for any reason short of a security incident.
- **A version number is never reused with other bytes.** A version found on the registry with other - **A version number is never reused with other bytes.** A version found on the registry with other
bytes is burned: investigate how it got there, and publish the train under a new version. bytes is burned: investigate how it got there, and publish the train under a new version.
- **No package cleanup rule** may be configured for the `River_of_Glory_Church` owner. - **No package cleanup rule** may be configured for the `Zayin` owner.
- **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests - **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests
check that. check that.
@@ -359,7 +359,7 @@ claim to be source that 0.1.2 never contained.
Before it, an administrator of the Gitea instance: Before it, an administrator of the Gitea instance:
1. creates the dedicated publisher account; 1. creates the dedicated publisher account;
2. creates a `River_of_Glory_Church` team, for example `package-publishers`, with only the Packages 2. creates a `Zayin` team, for example `package-publishers`, with only the Packages
unit at Write, and adds the account to it; unit at Write, and adds the account to it;
3. creates a token for the account with only `write:package`, and stores it as the repository 3. creates a token for the account with only `write:package`, and stores it as the repository
Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`; Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`;
+7 -1
View File
@@ -484,7 +484,13 @@ before it is installed: its digest, its archive safety (no links, special files,
escaping paths), every file's digest, and its identity and compatibility. The digest proves escaping paths), every file's digest, and its identity and compatibility. The digest proves
integrity, not who published the package; signing is not implemented. The verifier loads the backend integrity, not who published the package; signing is not implemented. The verifier loads the backend
in a short-lived process with an empty environment, so module code never sees a database URL or a in a short-lived process with an empty environment, so module code never sees a database URL or a
secret while it is being checked. secret while it is being checked. That load executes package code: `noexec` is retained on the
general `/tmp`, but native packages require the verifier's narrow, bounded execution scratch to be
executable. The one-shot hardened verifier container—not the scratch mount—is the isolation
boundary, and it is not claimed to be a perfect sandbox. Use the supported invocation in
[`deploy/README.md`](../../deploy/README.md#installed-modules), which also keeps the root filesystem
read-only, runs as the image's non-root user, disables networking and privilege escalation, and
drops Linux capabilities.
Two production defaults are relevant but are **not** by themselves a deployment guarantee: Two production defaults are relevant but are **not** by themselves a deployment guarantee:
+112 -6
View File
@@ -89,7 +89,7 @@ promises no API stability beyond the pre-1.0 SemVer policy. Only Ekklesia's rele
the registry in your repository's committed `.npmrc`; that is all the configuration there is: the registry in your repository's committed `.npmrc`; that is all the configuration there is:
```ini ```ini
@ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ @ekklesia:registry=https://git.zayin.net/api/packages/Zayin/npm/
``` ```
Then the module builds and packages like any other: Then the module builds and packages like any other:
@@ -296,9 +296,10 @@ The archive holds one directory named with your module id:
- **What it does not do.** A supplement never changes the licence the package declares. The - **What it does not do.** A supplement never changes the licence the package declares. The
supplements cover `@img/sharp-libvips-linux-x64` 1.3.3 and 1.3.4, the libvips binaries that supplements cover `@img/sharp-libvips-linux-x64` 1.3.3 and 1.3.4, the libvips binaries that
`sharp` 0.35.4 and 0.35.5 load; each matches only its own version and digest, and the packager's `sharp` 0.35.4 and 0.35.5 load; each matches only its own version and digest, and the packager's
native proof builds with sharp 0.35.5. They record upstream sources, but whether the LGPL and MPL native proof builds with sharp 0.35.5. A supplement identifies and verifies licence and notice
source obligations for those binaries are met is an open determination. A package that carries material only. Source availability for copyleft native code is a separate requirement, met by a
one is not yet treated as publicly distributable. corresponding-source artifact
([Distributing a native package publicly](#distributing-a-native-package-publicly)).
- **Deterministic.** The same packager version and the same built inputs produce the same archive, - **Deterministic.** The same packager version and the same built inputs produce the same archive,
byte for byte, wherever the checkout is. byte for byte, wherever the checkout is.
@@ -317,13 +318,113 @@ require exact agreement with Core. The legacy facade has not been removed.
`node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an `node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an
Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without
installing it. Pass `--extract-to <new directory>` to receive the verified module directory. installing it. Pass `--extract-to <new directory>` to receive the verified module directory.
[deploy/README.md](../../deploy/README.md#installed-modules) shows the whole installation. [deploy/README.md](../../deploy/README.md#installed-modules) shows the supported hardened container
invocation; use it rather than inventing mount flags. Backend verification executes package code.
Its container is therefore the isolation boundary: general `/tmp` remains `noexec`, while a narrow,
bounded, ephemeral verifier scratch tmpfs permits execution so native addons can load.
The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An
installed package normally has no source, so the platform cannot check it the same way. Your installed package normally has no source, so the platform cannot check it the same way. Your
module's own CI is where its boundary rules, its production dependencies, and its behaviour inside module's own CI is where its boundary rules, its production dependencies, and its behaviour inside
Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)). Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)).
#### Distributing a native package publicly
Building and testing a native package needs nothing beyond the packager. **Distributing it
publicly** does, when it redistributes native code under the GNU LGPL, the MPL, or a similar licence
with source obligations
([ADR 0055](../architecture/decisions/0055-corresponding-source-artifacts.md)).
- **Covered dependencies.** A native dependency is covered when its declared licence, or any
component its reviewed supplement records inside it, carries source obligations. libvips is one:
it declares `LGPL-3.0-or-later`, and cairo and several Rust crates inside it are under the MPL.
- **The policy.** A package with covered dependencies may be distributed publicly only together with
a corresponding-source artifact. The artifact holds the exact source, patches, build definitions,
and rebuild and relink instructions for those dependencies, as bytes rather than links. If the
artifact cannot be produced and verified, the package is not approved for public distribution.
This is Ekklesia's project compliance policy, not legal advice or certification. A package that
passes the gate meets Ekklesia's redistribution policy, and nothing more is claimed.
- **Source recipes.** For each covered dependency version, a reviewed recipe is checked in beside
the supplements, under `packages/module-pack/corresponding-sources/<package>/<version>/`. It holds
`recipe.json` and its documents, notably `BUILDING.md`. The recipe names the exact package (name,
version, content digest) and the exact supplement it was prepared with. It lists every component
with its role (`compiled`, `build-input`, or `build-definition`), licence, licence material, and
relationship to the binary. Every source file and patch is recorded by origin, size, and SHA-256,
and every exclusion has a reason. Like supplements, recipes are reviewed data, and the mechanism
knows no particular library.
Releasing a native package publicly takes three steps after packaging:
```bash
# 1. Acquisition: the only step that uses the network. It fetches each recorded file once into a
# cache keyed by SHA-256, refusing any response that differs from the recipe.
pnpm exec ekklesia-corresponding-source acquire --package ../packages/<id>-<version>-linux-x64-glibc.tar.gz \
--cache ~/.cache/ekklesia/corresponding-sources
# 2. Construction: offline. Every byte is verified against the recipe before anything is written.
pnpm exec ekklesia-corresponding-source build --package ../packages/<id>-<version>-linux-x64-glibc.tar.gz \
--cache ~/.cache/ekklesia/corresponding-sources --out ../packages
# 3. The public-distribution gate. Exit status 0 means approved.
pnpm exec ekklesia-corresponding-source verify --package ../packages/<id>-<version>-linux-x64-glibc.tar.gz \
--source ../packages/<id>-<version>-linux-x64-glibc-corresponding-source.tar.gz \
--metadata ../packages/<id>-<version>-linux-x64-glibc.redistribution.json
```
Construction writes four sibling release artifacts beside the package. The source archive never goes
inside the Module Package, and `THIRD_PARTY_NOTICES` is unchanged.
```text
<id>-<version>-linux-x64-glibc.tar.gz the Module Package (packager)
<id>-<version>-linux-x64-glibc.tar.gz.sha256
<id>-<version>-linux-x64-glibc-corresponding-source.tar.gz the corresponding source
<id>-<version>-linux-x64-glibc-corresponding-source.tar.gz.sha256
<id>-<version>-linux-x64-glibc.redistribution.json binary ↔ source, by digest
<id>-<version>-linux-x64-glibc.source-notice.txt the release notice
```
- **The source archive** is deterministic: sorted entries, fixed modes, ownership, and timestamps,
and no machine-specific path. The same package and recipe produce the same bytes in any directory.
- `corresponding-source.json` records every file with its SHA-256, and `SHA256SUMS` repeats them.
- Each covered dependency has its own `dependencies/<package>/<version>/` directory, with
`BUILDING.md`, the recipe, `PROVENANCE.md`, the supplement, and `sources/` and `patches/`.
- **The redistribution metadata** answers "which exact source corresponds to this exact binary?"
from digests. It records:
- the package file and its SHA-256, the module identity, and the target;
- each covered dependency's version and content digest, and the supplement and recipe it was
assembled from;
- the source archive's file name and SHA-256, and the SHA-256 of its manifest.
- **The release notice** must be shown alongside the binary wherever it is offered. It states that
the release includes third-party software under licences including the GNU LGPL and the MPL, and
that corresponding source and build and relink materials for the covered native dependencies are
in the accompanying artifact. It names that artifact by its exact file name and SHA-256.
- **Fail-closed.** The gate refuses a package when:
- a covered dependency has no recipe, or a recipe or supplement does not match its exact bytes;
- the source archive is missing, non-canonical, or differs from its manifest;
- the archive holds a file the manifest does not record, or a generated file names a local path;
- the metadata or the notice does not name these exact files.
It also refuses bundled (not copied) copyleft code, which this mechanism does not cover. A package
with no covered native code is approved without an artifact.
- **Retention.** Keep a release's source artifact for as long as its binary release is distributed
or supported. Never delete one automatically while the binary remains available. Never replace its
bytes under the same name. A correction is a new release, or an explicitly versioned compliance
artifact.
**Available today:** a recipe for `@img/sharp-libvips-linux-x64` 1.3.4, used by sharp 0.35.5. Its
artifact is about 200 MB: 27 library archives, 5 patches, 353 Rust crates, and the Rust standard
library source of the recorded toolchain. Following its `BUILDING.md` rebuilds
`libvips-cpp.so.8.18.7` from that source alone. libvips 1.3.3 (sharp 0.35.4) has a supplement but no
recipe, so a package with it builds and tests as before but is not approved for public distribution.
`pnpm test:corresponding-source` runs the whole proof for the native proof module: acquisition into a
verified cache, two byte-identical builds, the gate, and sharp running from the package.
**Adding a native dependency** follows the same path as libvips:
- curate and review its supplement (ADR 0052);
- curate and review its source recipe and `BUILDING.md`, recording every source input the
dependency's build fetches;
- prove both with the native packaging tests and `pnpm test:corresponding-source`.
### License ### License
Ekklesia is licensed under the GNU Affero General Public License, version 3 or (at your option) any Ekklesia is licensed under the GNU Affero General Public License, version 3 or (at your option) any
@@ -434,7 +535,12 @@ without executing module code:
} }
``` ```
Module, capability, and permission identifiers are lowercase and namespaced. A provided capability Module, capability, and permission identifiers are lowercase and namespaced. A module with a
Dashboard UI is addressed at `/<module-id>` — `/calendar` for this one
([ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)) — in the same
top-level URL space as the platform's own pages, so a few ids are **reserved** and Core refuses a
module that declares one (`MODULE_ID_RESERVED`, reported by discovery, `pnpm modules:validate`, and
package verification): `account`, `api`, `assets`, `display`, and `settings`. A provided capability
has its own exact semantic version because its public contract can evolve independently from the has its own exact semantic version because its public contract can evolve independently from the
module release. Capability dependencies use semantic-version ranges. Optional means the provider module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible. may be absent; if it is installed, its capability version must be compatible.
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ekklesia", "name": "ekklesia",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -43,6 +43,7 @@
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh", "test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
"test:root": "vitest run --config vitest.config.ts", "test:root": "vitest run --config vitest.config.ts",
"test:package-release": "bash scripts/test-package-release.sh", "test:package-release": "bash scripts/test-package-release.sh",
"test:corresponding-source": "bash scripts/test-corresponding-source.sh",
"test:package-publication": "bash scripts/test-package-publication.sh", "test:package-publication": "bash scripts/test-package-publication.sh",
"test:watch": "vitest", "test:watch": "vitest",
"typecheck": "turbo run typecheck typecheck:root", "typecheck": "turbo run typecheck typecheck:root",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/contracts", "name": "@ekklesia/contracts",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+22
View File
@@ -87,6 +87,28 @@ export class ModuleStorageError extends Error {
} }
} }
/**
* Whether `value` is a module-storage error, judged by its stable public shape rather than its class.
*
* An installed module bundles its own copy of the contracts package (ADR 0049), so an error created
* by the host's storage provider does not share class identity with the module's
* `ModuleStorageError`. `instanceof ModuleStorageError` is therefore not a valid check across the
* module boundary.
*/
export function isModuleStorageError(
value: unknown,
): value is Pick<ModuleStorageError, "code" | "key" | "message" | "name"> {
if (!(value instanceof Error) || value.name !== "ModuleStorageError") {
return false;
}
const { code, key } = value as { code?: unknown; key?: unknown };
return (
typeof code === "string" &&
(MODULE_STORAGE_ERROR_CODES as readonly string[]).includes(code) &&
(key === undefined || typeof key === "string")
);
}
/** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes, /** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes,
* not characters, because that is what the wire format bounds. */ * not characters, because that is what the wire format bounds. */
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024; export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/design-tokens", "name": "@ekklesia/design-tokens",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/module-database", "name": "@ekklesia/module-database",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
// Committed launcher, deliberately not generated: see ekklesia-pack-module.mjs for why. It defers to
// the compiled CLI at run time.
import { fileURLToPath } from "node:url";
const compiledCli = new URL("../dist/corresponding-source-cli.js", import.meta.url);
try {
await import(compiledCli.href);
} catch (error) {
if (error?.code === "ERR_MODULE_NOT_FOUND") {
process.stderr.write(
`@ekklesia/module-pack is not built yet (${fileURLToPath(compiledCli)}).\n` +
"Run `pnpm build` from the repository root first.\n",
);
process.exitCode = 1;
} else {
throw error;
}
}
@@ -0,0 +1,144 @@
# Rebuilding `@img/sharp-libvips-linux-x64` 1.3.4
This directory is the corresponding source for one redistributed native binary. These instructions
say how to rebuild it from the source here, and how to use a rebuilt or modified library in place of
the redistributed one. They are technical instructions. They are not a legal opinion, and they have
not been legally certified.
## What this corresponds to
| | |
| --- | --- |
| Package | `@img/sharp-libvips-linux-x64` 1.3.4, from the npm registry |
| Declared licence | `LGPL-3.0-or-later` |
| Content digest | `ekklesia-content-v1:sha256:2927fe11493461fa34847191d0b9b1c6441007c3e99f306e5708cb07fb0a5e59` |
| Binary | `lib/libvips-cpp.so.8.18.7` (SONAME `libvips-cpp.so.8.18.7`) |
| Target | Linux, x86-64, glibc 2.28 or newer |
| Used by | `sharp` 0.35.5, through `@img/sharp-linux-x64` 0.35.5 |
`lib/libvips-cpp.so.8.18.7` is one shared object. libvips and the libraries it uses are built as
static libraries and linked into it: 27 C and C++ libraries, glib's bundled gvdb, librsvg with its
Rust crates, and the Rust standard library. `recipe.json` lists every component, its licence, its
source file and patches with their SHA-256, and its relationship to the binary. `PROVENANCE.md`
records how all of this was established, and `licenses/` holds the reviewed licence and notice
material.
## What is here
- `sources/sharp-libvips-1.3.4.tar.gz`: the build definitions that produced the binary. They are
`build.sh`, `build/posix.sh`, `platforms/linux-x64/` (a Dockerfile and its toolchain files), and
`versions.properties`.
- `sources/*.tar.*`: the source archive of every compiled library, exactly as `build/posix.sh`
downloads it.
- `patches/`: the five patches `build/posix.sh` applies: glib, mozjpeg, librsvg, libultrahdr, and
libvips.
- `sources/crates/`: all 353 Rust crates librsvg's resolved `Cargo.lock` records.
- 147 are compiled into the binary.
- 17 are needed only to build it: build-script dependencies, and one procedural macro that adds no
code.
- 189 serve librsvg's other workspace members, its tests, or other platforms. Cargo resolves the
whole workspace lockfile even when it builds one package, so it needs them present to build
offline. None of them is compiled into the binary.
`recipe.json` records each crate's role.
- `sources/rust/rust-src-nightly-2026-09-27.tar.xz`: the standard-library source of the Rust toolchain
that built librsvg, `rustc 1.101.0-nightly (75a75c3e0 2026-09-26)`.
- `build/`: the resolved librsvg `Cargo.lock`, and the two scripts below that point the unchanged build
logic at these files.
Every file's SHA-256 is in `recipe.json` and in the archive's top-level `corresponding-source.json`.
Check them before building.
## How the original was built
sharp-libvips builds the binary in a Rocky Linux 8 container, which pins glibc 2.28:
```sh
./build.sh linux-x64
# = docker build --pull -t vips-dev-linux-x64 platforms/linux-x64
# docker run --rm -v "$PWD:/packaging" vips-dev-linux-x64 sh -c /packaging/build/posix.sh
```
`platforms/linux-x64/Dockerfile` installs the toolchain: gcc-toolset-15, cmake, nasm, pkgconf, meson
and ninja (from pip), a nightly Rust toolchain through rustup, and cargo-c. `build/posix.sh` then
downloads each library's archive, applies the patches, builds every library as a static library into
`/target`, and links libvips-cpp. It packs the result as `sharp-libvips-linux-x64.tar.gz`, whose
`lib/libvips-cpp.so.8.18.7` is the file the npm package ships. Meson runs with
`wrap_mode = 'nofallback'` (`platforms/linux-x64/meson.ini`), so it downloads no subproject. The only
forced fallback, gvdb, builds from the copy inside glib's archive.
## Rebuilding from this source
You need a Linux x86-64 host with Docker. Building the container image needs network access to fetch
the toolchain (Rocky Linux packages, rustup, pip, and crates.io for cargo-c). Source for the library
itself comes only from this directory.
1. **Unpack the build definitions.**
```sh
tar -xzf sources/sharp-libvips-1.3.4.tar.gz # creates sharp-libvips-1.3.4/
```
2. **Point the build at this source.**
```sh
sh build/prepare-offline.sh sharp-libvips-1.3.4
```
It copies the archives and patches into `sharp-libvips-1.3.4/offline/`. It unpacks the crates into
a vendored directory source; Cargo checks each against the lockfile's checksum. It then makes three
mechanical edits:
- in `build/posix.sh`, `CURL` becomes `offline/offline-curl.sh`. That script answers each URL the
script would download with its local copy, using `build/url-map.tsv`, and fails for any other URL;
- in `build/posix.sh`, `cargo update --workspace` becomes a copy of the resolved `Cargo.lock`. Cargo
is set offline and uses the vendored crates;
- in `platforms/linux-x64/Dockerfile`, the Rust toolchain is pinned to `nightly-2026-09-27`, the
toolchain whose `rustc` built the original. The upstream file installs whatever nightly is
current.
Nothing that is compiled changes: every patch is still applied by `build/posix.sh`, exactly as
upstream.
3. **Build.**
```sh
cd sharp-libvips-1.3.4
./build.sh linux-x64
```
The result is `sharp-libvips-1.3.4/sharp-libvips-linux-x64.tar.gz`. Its
`lib/libvips-cpp.so.8.18.7` is the rebuilt library. A full build takes from tens of minutes to an
hour, depending on the host.
**Modifying a component.** Before step 3, change the source the build uses. Then `build/posix.sh`
compiles your version in place of the original:
- for a C or C++ library, replace its archive in `offline/files/` under the same name, or add a patch
to the matching step of `build/posix.sh`;
- for a Rust crate, edit it under `offline/vendor/`. The `.cargo-checksum.json` files list no
per-file checksums, so an edited crate still builds.
The result is not byte-identical to the redistributed binary: a compiler toolchain, timestamps, and
paths that were not recorded can all differ. It is built from the same source, by the same
definitions, for the same target.
## Using a rebuilt or modified library
sharp's native addon, `@img/sharp-linux-x64/lib/sharp-linux-x64-0.35.5.node`, links
`libvips-cpp.so.8.18.7` dynamically. Its RPATH begins with `$ORIGIN/../../sharp-libvips-linux-x64/lib`.
In an installed or extracted Ekklesia Module Package, that resolves to:
```text
<module directory>/node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.18.7
```
To use your library, replace that file with your build's `lib/libvips-cpp.so.8.18.7`. Keep the file
name and SONAME, and keep libvips's C++ API compatible with libvips 8.18, which sharp 0.35.5 is
compiled against. Then restart the process that loads the module. No other file changes, and the
addon does not need rebuilding.
`ldd <module directory>/node_modules/@img/sharp-linux-x64/lib/sharp-linux-x64-0.35.5.node` shows which
`libvips-cpp.so.8.18.7` the addon resolves.
A module package's own `ekklesia.package.json` records the SHA-256 of each file it shipped, so a
modified tree is no longer the package Ekklesia verified at installation. Install it as your own
build.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
#!/bin/sh
# Stands in for curl in sharp-libvips's build/posix.sh, so the build reads only this artifact's
# sources. Given the URL posix.sh would download, it prints the recorded local copy, or with -O
# writes it to the current directory under the URL's file name. A URL with no recorded copy fails:
# the build never reaches the network for source.
set -eu
write=""
url=""
for argument in "$@"; do
case "$argument" in
-O) write=yes ;;
-*) ;;
*) url="$argument" ;;
esac
done
file="$(awk -F '\t' -v url="$url" '$1 == url { print $2 }' /packaging/offline/url-map.tsv)"
if [ -z "$file" ]; then
echo "offline-curl: no recorded source for $url" >&2
exit 22
fi
if [ -n "$write" ]; then
cp "/packaging/offline/files/$file" "./${url##*/}"
else
cat "/packaging/offline/files/$file"
fi
@@ -0,0 +1,61 @@
#!/bin/sh
# Prepares an extracted sharp-libvips v1.3.4 source tree to build lib/libvips-cpp.so.8.18.7 from this
# artifact's sources alone. BUILDING.md describes each step.
#
# sh build/prepare-offline.sh <extracted sharp-libvips-1.3.4 directory>
#
# Only the source acquisition changes; nothing that is compiled does:
# - every archive and patch build/posix.sh downloads is read from this artifact (offline-curl.sh);
# - librsvg's Rust crates come from this artifact as a vendored directory source, and its lockfile
# is the resolved one recorded here instead of a fresh `cargo update` against crates.io;
# - platforms/linux-x64/Dockerfile installs the exact nightly Rust toolchain the binary records.
set -eu
here="$(cd "$(dirname "$0")/.." && pwd)"
tree="$(cd "$1" && pwd)"
[ -f "$tree/build/posix.sh" ] && [ -f "$tree/platforms/linux-x64/Dockerfile" ] || {
echo "prepare-offline: $1 is not an extracted sharp-libvips source tree." >&2
exit 1
}
offline="$tree/offline"
rm -rf "$offline"
mkdir -p "$offline/files" "$offline/vendor"
cp "$here"/sources/*.tar.* "$here"/patches/*.patch "$offline/files/"
cp "$tree/THIRD-PARTY-NOTICES.md" "$offline/files/THIRD-PARTY-NOTICES.md"
cp "$here/build/url-map.tsv" "$here/build/Cargo.lock" "$offline/"
cp "$here/build/offline-curl.sh" "$offline/offline-curl.sh"
chmod 755 "$offline/offline-curl.sh"
for crate in "$here"/sources/crates/*.crate; do
tar -xzf "$crate" -C "$offline/vendor"
name="$(basename "$crate" .crate)"
checksum="$(sha256sum "$crate" | cut -d ' ' -f 1)"
printf '{"files":{},"package":"%s"}\n' "$checksum" > "$offline/vendor/$name/.cargo-checksum.json"
done
cat > "$offline/cargo-config.toml" <<'CONFIG'
[source.crates-io]
replace-with = "vendored-sources"
[source.vendored-sources]
directory = "/packaging/offline/vendor"
[net]
offline = true
CONFIG
posix="$tree/build/posix.sh"
grep -q '^CURL="curl ' "$posix" && grep -q '^cargo update --workspace$' "$posix" || {
echo "prepare-offline: build/posix.sh is not the v1.3.4 script this artifact was prepared for." >&2
exit 1
}
sed -i \
-e 's|^CURL="curl .*"$|CURL="/packaging/offline/offline-curl.sh"\nexport CARGO_NET_OFFLINE=true\nmkdir -p "${CARGO_HOME:-$HOME/.cargo}" \&\& cp /packaging/offline/cargo-config.toml "${CARGO_HOME:-$HOME/.cargo}/config.toml"|' \
-e 's|^cargo update --workspace$|cp /packaging/offline/Cargo.lock Cargo.lock|' \
"$posix"
dockerfile="$tree/platforms/linux-x64/Dockerfile"
grep -q -- '--default-toolchain nightly ' "$dockerfile" || {
echo "prepare-offline: platforms/linux-x64/Dockerfile is not the v1.3.4 one." >&2
exit 1
}
sed -i 's|--default-toolchain nightly |--default-toolchain nightly-2026-09-27 |' "$dockerfile"
echo "prepare-offline: $tree builds from this artifact's sources."
@@ -0,0 +1,33 @@
https://cairographics.org/releases/cairo-1.18.6.tar.xz cairo-1.18.6.tar.xz
https://cairographics.org/releases/pixman-0.46.4.tar.gz pixman-0.46.4.tar.gz
https://download.gnome.org/sources/glib/2.90/glib-2.90.0.tar.xz glib-2.90.0.tar.xz
https://download.gnome.org/sources/librsvg/2.63/librsvg-2.63.2.tar.xz librsvg-2.63.2.tar.xz
https://download.gnome.org/sources/libxml2/2.15/libxml2-2.15.4.tar.xz libxml2-2.15.4.tar.xz
https://download.gnome.org/sources/pango/1.58/pango-1.58.2.tar.xz pango-1.58.2.tar.xz
https://gist.github.com/kleisauke/284d685efa00908da99ea6afbaaf39ae/raw/bdad5489a61c217850631571caf57f5db6ea8b2c/glib-without-gregex.patch glib-without-gregex.patch
https://gist.githubusercontent.com/lovell/313a6901e9db1bf285f2a1f1180499e4/raw/3988223c7dfa4d22745d9392034b0117abef1446/libvips-cpp-soversion.patch libvips-cpp-soversion.patch
https://github.com/GNOME/librsvg/commit/9106011db93d701728ac2c9da50c9ab2c1bb5dc6.patch librsvg-9106011db93d701728ac2c9da50c9ab2c1bb5dc6.patch
https://github.com/dloebl/cgif/archive/v0.5.4.tar.gz cgif-0.5.4.tar.gz
https://github.com/freetype/freetype/archive/VER-2-14-3.tar.gz freetype-2.14.3.tar.gz
https://github.com/fribidi/fribidi/releases/download/v1.0.17/fribidi-1.0.17.tar.xz fribidi-1.0.17.tar.xz
https://github.com/google/highway/archive/1.4.0.tar.gz highway-1.4.0.tar.gz
https://github.com/google/libultrahdr/archive/v2.0.2.tar.gz libultrahdr-2.0.2.tar.gz
https://github.com/harfbuzz/harfbuzz/archive/14.5.0.tar.gz harfbuzz-14.5.0.tar.gz
https://github.com/libarchive/libarchive/releases/download/v3.8.9/libarchive-3.8.9.tar.xz libarchive-3.8.9.tar.xz
https://github.com/libexif/libexif/releases/download/v0.6.26/libexif-0.6.26.tar.xz libexif-0.6.26.tar.xz
https://github.com/libexpat/libexpat/releases/download/R_2_8_5/expat-2.8.5.tar.xz expat-2.8.5.tar.xz
https://github.com/libffi/libffi/releases/download/v3.8.0/libffi-3.8.0.tar.gz libffi-3.8.0.tar.gz
https://github.com/libvips/libvips/releases/download/v8.18.7/vips-8.18.7.tar.xz vips-8.18.7.tar.xz
https://github.com/lovell/libimagequant/archive/v2.4.1.tar.gz libimagequant-2.4.1.tar.gz
https://github.com/mm2/Little-CMS/releases/download/lcms2.19.1/lcms2-2.19.1.tar.gz lcms2-2.19.1.tar.gz
https://github.com/mozilla/mozjpeg/archive/0826579.tar.gz mozjpeg-0826579.tar.gz
https://github.com/mozilla/mozjpeg/commit/f90668e0e4fb79c81e1f24a0ccc0e2090af761bf.patch mozjpeg-f90668e0e4fb79c81e1f24a0ccc0e2090af761bf.patch
https://github.com/pnggroup/libpng/archive/v1.6.58.tar.gz libpng-1.6.58.tar.gz
https://github.com/strukturag/libheif/releases/download/v1.23.5/libheif-1.23.5.tar.gz libheif-1.23.5.tar.gz
https://github.com/zlib-ng/zlib-ng/archive/2.3.3.tar.gz zlib-ng-2.3.3.tar.gz
https://gitlab.com/libtiff/libtiff/-/archive/v4.7.2/libtiff-v4.7.2.tar.gz libtiff-v4.7.2.tar.gz
https://gitlab.freedesktop.org/fontconfig/fontconfig/-/archive/2.18.3/fontconfig-2.18.3.tar.gz fontconfig-2.18.3.tar.gz
https://patch-diff.githubusercontent.com/raw/google/libultrahdr/pull/383.patch libultrahdr-pull-383.patch
https://raw.githubusercontent.com/lovell/sharp-libvips/main/THIRD-PARTY-NOTICES.md THIRD-PARTY-NOTICES.md
https://storage.googleapis.com/aom-releases/libaom-3.15.1.tar.gz libaom-3.15.1.tar.gz
https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.6.0.tar.gz libwebp-1.6.0.tar.gz
1 https://cairographics.org/releases/cairo-1.18.6.tar.xz cairo-1.18.6.tar.xz
2 https://cairographics.org/releases/pixman-0.46.4.tar.gz pixman-0.46.4.tar.gz
3 https://download.gnome.org/sources/glib/2.90/glib-2.90.0.tar.xz glib-2.90.0.tar.xz
4 https://download.gnome.org/sources/librsvg/2.63/librsvg-2.63.2.tar.xz librsvg-2.63.2.tar.xz
5 https://download.gnome.org/sources/libxml2/2.15/libxml2-2.15.4.tar.xz libxml2-2.15.4.tar.xz
6 https://download.gnome.org/sources/pango/1.58/pango-1.58.2.tar.xz pango-1.58.2.tar.xz
7 https://gist.github.com/kleisauke/284d685efa00908da99ea6afbaaf39ae/raw/bdad5489a61c217850631571caf57f5db6ea8b2c/glib-without-gregex.patch glib-without-gregex.patch
8 https://gist.githubusercontent.com/lovell/313a6901e9db1bf285f2a1f1180499e4/raw/3988223c7dfa4d22745d9392034b0117abef1446/libvips-cpp-soversion.patch libvips-cpp-soversion.patch
9 https://github.com/GNOME/librsvg/commit/9106011db93d701728ac2c9da50c9ab2c1bb5dc6.patch librsvg-9106011db93d701728ac2c9da50c9ab2c1bb5dc6.patch
10 https://github.com/dloebl/cgif/archive/v0.5.4.tar.gz cgif-0.5.4.tar.gz
11 https://github.com/freetype/freetype/archive/VER-2-14-3.tar.gz freetype-2.14.3.tar.gz
12 https://github.com/fribidi/fribidi/releases/download/v1.0.17/fribidi-1.0.17.tar.xz fribidi-1.0.17.tar.xz
13 https://github.com/google/highway/archive/1.4.0.tar.gz highway-1.4.0.tar.gz
14 https://github.com/google/libultrahdr/archive/v2.0.2.tar.gz libultrahdr-2.0.2.tar.gz
15 https://github.com/harfbuzz/harfbuzz/archive/14.5.0.tar.gz harfbuzz-14.5.0.tar.gz
16 https://github.com/libarchive/libarchive/releases/download/v3.8.9/libarchive-3.8.9.tar.xz libarchive-3.8.9.tar.xz
17 https://github.com/libexif/libexif/releases/download/v0.6.26/libexif-0.6.26.tar.xz libexif-0.6.26.tar.xz
18 https://github.com/libexpat/libexpat/releases/download/R_2_8_5/expat-2.8.5.tar.xz expat-2.8.5.tar.xz
19 https://github.com/libffi/libffi/releases/download/v3.8.0/libffi-3.8.0.tar.gz libffi-3.8.0.tar.gz
20 https://github.com/libvips/libvips/releases/download/v8.18.7/vips-8.18.7.tar.xz vips-8.18.7.tar.xz
21 https://github.com/lovell/libimagequant/archive/v2.4.1.tar.gz libimagequant-2.4.1.tar.gz
22 https://github.com/mm2/Little-CMS/releases/download/lcms2.19.1/lcms2-2.19.1.tar.gz lcms2-2.19.1.tar.gz
23 https://github.com/mozilla/mozjpeg/archive/0826579.tar.gz mozjpeg-0826579.tar.gz
24 https://github.com/mozilla/mozjpeg/commit/f90668e0e4fb79c81e1f24a0ccc0e2090af761bf.patch mozjpeg-f90668e0e4fb79c81e1f24a0ccc0e2090af761bf.patch
25 https://github.com/pnggroup/libpng/archive/v1.6.58.tar.gz libpng-1.6.58.tar.gz
26 https://github.com/strukturag/libheif/releases/download/v1.23.5/libheif-1.23.5.tar.gz libheif-1.23.5.tar.gz
27 https://github.com/zlib-ng/zlib-ng/archive/2.3.3.tar.gz zlib-ng-2.3.3.tar.gz
28 https://gitlab.com/libtiff/libtiff/-/archive/v4.7.2/libtiff-v4.7.2.tar.gz libtiff-v4.7.2.tar.gz
29 https://gitlab.freedesktop.org/fontconfig/fontconfig/-/archive/2.18.3/fontconfig-2.18.3.tar.gz fontconfig-2.18.3.tar.gz
30 https://patch-diff.githubusercontent.com/raw/google/libultrahdr/pull/383.patch libultrahdr-pull-383.patch
31 https://raw.githubusercontent.com/lovell/sharp-libvips/main/THIRD-PARTY-NOTICES.md THIRD-PARTY-NOTICES.md
32 https://storage.googleapis.com/aom-releases/libaom-3.15.1.tar.gz libaom-3.15.1.tar.gz
33 https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.6.0.tar.gz libwebp-1.6.0.tar.gz
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,15 +1,17 @@
{ {
"name": "@ekklesia/module-pack", "name": "@ekklesia/module-pack",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
"bin", "bin",
"corresponding-sources",
"dist", "dist",
"license-supplements", "license-supplements",
"licenses" "licenses"
], ],
"bin": { "bin": {
"ekklesia-corresponding-source": "./bin/ekklesia-corresponding-source.mjs",
"ekklesia-pack-module": "./bin/ekklesia-pack-module.mjs" "ekklesia-pack-module": "./bin/ekklesia-pack-module.mjs"
}, },
"exports": { "exports": {
@@ -0,0 +1,155 @@
#!/usr/bin/env node
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import {
acquireCorrespondingSources,
buildCorrespondingSource,
CorrespondingSourceError,
verifyRedistribution,
} from "./corresponding-source.js";
/**
* `ekklesia-corresponding-source` — corresponding-source artifacts for native packages (ADR 0055).
*
* ekklesia-corresponding-source acquire (--package <module package> | --recipe <name>@<version>) --cache <directory>
* Downloads every recorded source file the package's covered native dependencies (or the named
* recipe) need into the verified cache. The only subcommand that uses the network.
* ekklesia-corresponding-source build --package <module package> --cache <directory> --out <directory>
* Offline. Writes <package>-corresponding-source.tar.gz, its .sha256, <package>.redistribution.json,
* and <package>.source-notice.txt.
* ekklesia-corresponding-source verify --package <module package> [--source <archive> --metadata <json>]
* The public-distribution gate. Exits 0 only when the package is approved.
*/
const USAGE = [
"Usage:",
" ekklesia-corresponding-source acquire (--package <archive> | --recipe <name>@<version>) --cache <directory>",
" ekklesia-corresponding-source build --package <archive> --cache <directory> --out <directory>",
" ekklesia-corresponding-source verify --package <archive> [--source <archive> --metadata <file>]",
].join("\n");
const OPTIONS: Readonly<Record<string, readonly string[]>> = Object.freeze({
acquire: ["--package", "--recipe", "--cache"],
build: ["--package", "--cache", "--out"],
verify: ["--package", "--source", "--metadata"],
});
const REQUIRED: Readonly<Record<string, readonly string[]>> = Object.freeze({
acquire: ["--cache"],
build: ["--package", "--cache", "--out"],
verify: ["--package"],
});
class ArgumentsError extends Error {}
export function parseCorrespondingSourceArguments(argv: readonly string[]): {
readonly command: "acquire" | "build" | "verify";
readonly values: ReadonlyMap<string, string>;
} {
const [command, ...rest] = argv;
const allowed = command === undefined ? undefined : OPTIONS[command];
if (allowed === undefined) {
throw new ArgumentsError(USAGE);
}
const values = new Map<string, string>();
for (let index = 0; index < rest.length; index += 2) {
const option = rest[index] ?? "";
const value = rest[index + 1];
if (
!allowed.includes(option) ||
value === undefined ||
value.startsWith("--") ||
values.has(option)
) {
throw new ArgumentsError(USAGE);
}
values.set(option, option === "--recipe" ? value : path.resolve(value));
}
if (command === "acquire" && values.has("--package") === values.has("--recipe")) {
throw new ArgumentsError(USAGE);
}
if ((REQUIRED[command as string] ?? []).some((option) => !values.has(option))) {
throw new ArgumentsError(USAGE);
}
return { command: command as "acquire" | "build" | "verify", values };
}
async function main(): Promise<number> {
try {
const { command, values } = parseCorrespondingSourceArguments(process.argv.slice(2));
const packageArchivePath = values.get("--package") ?? "";
if (command === "acquire") {
const recipe = values.get("--recipe");
const at = recipe?.lastIndexOf("@") ?? -1;
if (recipe !== undefined && at <= 0) {
throw new ArgumentsError(USAGE);
}
const result = await acquireCorrespondingSources({
cacheDirectory: values.get("--cache") ?? "",
...(recipe === undefined
? { packageArchivePath }
: { recipes: [{ name: recipe.slice(0, at), version: recipe.slice(at + 1) }] }),
});
process.stdout.write(
`Source cache holds all ${result.files} recorded files (${result.bytes} bytes); fetched ${result.fetched}.\n`,
);
} else if (command === "build") {
const result = await buildCorrespondingSource({
cacheDirectory: values.get("--cache") ?? "",
outDirectory: values.get("--out") ?? "",
packageArchivePath,
});
if (!result.required) {
process.stdout.write(
"The package redistributes no covered native code: no artifact is needed.\n",
);
} else {
process.stdout.write(
`Wrote ${path.basename(result.sourceArchivePath ?? "")}, ${result.sourceBytes} bytes\nsha256 ${result.sourceSha256}\n`,
);
}
} else {
const metadataPath = values.get("--metadata");
const sourceArchivePath = values.get("--source");
const approval = await verifyRedistribution({
packageArchivePath,
...(metadataPath === undefined ? {} : { metadataPath }),
...(sourceArchivePath === undefined ? {} : { sourceArchivePath }),
});
process.stdout.write(
approval.correspondingSource === undefined
? `Approved: ${approval.package.file} redistributes no covered native code.\n`
: `Approved: ${approval.package.file} with ${approval.correspondingSource.file} (sha256 ${approval.correspondingSource.sha256}).\n`,
);
}
return 0;
} catch (error) {
if (error instanceof ArgumentsError) {
process.stderr.write(`${error.message}\n`);
return 64;
}
if (error instanceof CorrespondingSourceError) {
process.stderr.write(
`${error.code}: ${error.message}${error.subject ? ` (${error.subject})` : ""}\n`,
);
return 1;
}
process.stderr.write("ekklesia-corresponding-source failed unexpectedly.\n");
return 1;
}
}
function isDirectExecution(): boolean {
const entrypoint = process.argv[1];
// Run through the committed launcher, which imports this file, or directly.
return (
entrypoint !== undefined &&
(import.meta.url === pathToFileURL(path.resolve(entrypoint)).href ||
path.basename(entrypoint) === "ekklesia-corresponding-source.mjs" ||
path.basename(entrypoint) === "ekklesia-corresponding-source")
);
}
if (isDirectExecution()) {
process.exitCode = await main();
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1 +1,2 @@
export * from "./corresponding-source.js";
export * from "./pack-module.js"; export * from "./pack-module.js";
+99 -1
View File
@@ -1,4 +1,4 @@
import { gzipSync } from "node:zlib"; import { gunzipSync, gzipSync } from "node:zlib";
/** /**
* The deterministic archive encoding of Ekklesia Module Package v1 (ADR 0049). * The deterministic archive encoding of Ekklesia Module Package v1 (ADR 0049).
@@ -107,3 +107,101 @@ export function deterministicTarGz(topLevel: string, files: ReadonlyMap<string,
parts.push(Buffer.alloc(BLOCK * 2)); parts.push(Buffer.alloc(BLOCK * 2));
return gzipSync(Buffer.concat(parts), { level: GZIP_LEVEL }); return gzipSync(Buffer.concat(parts), { level: GZIP_LEVEL });
} }
/** One entry of a ustar archive, as read back. */
export interface TarEntry {
readonly path: string;
readonly directory: boolean;
readonly data: Buffer;
}
export class TarFormatError extends Error {
constructor() {
super("An archive is not in Ekklesia's deterministic ustar encoding.");
this.name = "TarFormatError";
}
}
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");
}
/**
* Reads an archive written by `deterministicTarGz` back into its entries, refusing anything that
* encoding never produces: a link or special file, another header type, a bad checksum, or data
* after the end blocks. Whether the archive is byte-for-byte canonical is checked separately, by
* writing the entries again (`isCanonicalTarGz`).
*/
export function readTarGz(archive: Buffer): readonly TarEntry[] {
let raw: Buffer;
try {
raw = gunzipSync(archive);
} catch {
throw new TarFormatError();
}
const entries: TarEntry[] = [];
let offset = 0;
for (;;) {
if (offset + BLOCK > raw.length) {
throw new TarFormatError();
}
const block = raw.subarray(offset, offset + BLOCK);
if (block.every((byte) => byte === 0)) {
break;
}
const stored = Number.parseInt(field(block, 148, 8).trim() || "x", 8);
let checksum = 0;
for (let index = 0; index < BLOCK; index += 1) {
checksum += index >= 148 && index < 156 ? 32 : (block[index] ?? 0);
}
const typeflag = field(block, 156, 1);
const size = Number.parseInt(field(block, 124, 12) || "x", 8);
if (
stored !== checksum ||
(typeflag !== "0" && typeflag !== "5") ||
!Number.isSafeInteger(size)
) {
throw new TarFormatError();
}
const name = field(block, 0, 100);
const prefix = field(block, 345, 155);
const joined = prefix === "" ? name : `${prefix}/${name}`;
const directory = typeflag === "5";
const start = offset + BLOCK;
if (start + size > raw.length) {
throw new TarFormatError();
}
entries.push({
data: Buffer.from(raw.subarray(start, start + size)),
directory,
path: directory ? joined.replace(/\/$/u, "") : joined,
});
offset = start + Math.ceil(size / BLOCK) * BLOCK;
}
return entries;
}
/** Whether `archive` is exactly what `deterministicTarGz` writes for its own entries: the same
* order, headers, and padding. Compared uncompressed, so a different zlib cannot matter. */
export function isCanonicalTarGz(archive: Buffer, entries: readonly TarEntry[]): boolean {
const topLevel = entries[0];
if (topLevel === undefined || !topLevel.directory || topLevel.path.includes("/")) {
return false;
}
const files = new Map<string, Buffer>();
for (const entry of entries) {
if (!entry.directory) {
if (!entry.path.startsWith(`${topLevel.path}/`)) {
return false;
}
files.set(entry.path.slice(topLevel.path.length + 1), entry.data);
}
}
try {
return gunzipSync(deterministicTarGz(topLevel.path, files)).equals(gunzipSync(archive));
} catch {
return false;
}
}
@@ -0,0 +1,342 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { currentModulePackageHost, verifyModulePackage } from "@ekklesia/core/modules";
import { afterEach, describe, expect, it } from "vitest";
import {
buildCorrespondingSource,
CorrespondingSourceError,
loadSourceRecipes,
verifyRedistribution,
} from "../src/corresponding-source.js";
import { packModule } from "../src/index.js";
import { NATIVE_TARGETS, nativeClosure } from "../src/native.js";
import {
contentDigest,
DEFAULT_SUPPLEMENTS_DIRECTORY,
loadLicenseSupplements,
} from "../src/supplements.js";
import { readTarGz } from "../src/tar.js";
import {
cleanupFixtures,
installedPackageFile,
nativeFixtureModule,
SOURCE_REVISION,
scratchDirectory,
} from "./support/fixture-module.js";
/**
* ADR 0055's first complete proof: the native proof module with sharp 0.35.5, whose libvips 1.3.4
* binary contains LGPL and MPL components, and its real corresponding-source artifact.
*
* The recipe checks run everywhere. Building the artifact needs the recorded sources in a verified
* cache, which `pnpm test:corresponding-source` acquires once and passes in as
* EKKLESIA_CORRESPONDING_SOURCE_CACHE; without it, that part is skipped rather than downloading
* about 200 MB on every run.
*/
const execFileAsync = promisify(execFile);
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repositoryRoot = path.resolve(packageRoot, "..", "..");
const LIBVIPS = "@img/sharp-libvips-linux-x64";
const cache = process.env.EKKLESIA_CORRESPONDING_SOURCE_CACHE;
const host = currentModulePackageHost();
const onTargetHost =
host.platform === "linux" && host.arch === "x64" && host.libc?.family === "glibc";
afterEach(cleanupFixtures);
// `pnpm test:corresponding-source` asks for the full proof: then a missing cache is a failure, not a
// skip.
it.runIf(process.env.EKKLESIA_CORRESPONDING_SOURCE_REQUIRED === "1")(
"has the verified source cache it was asked to use, on a linux-x64-glibc host",
() => {
expect(onTargetHost).toBe(true);
expect(cache !== undefined && existsSync(cache)).toBe(true);
},
);
describe("the libvips 1.3.4 source recipe", () => {
it("loads intact, matches its reviewed supplement exactly, and pins the installed package", async () => {
const recipes = await loadSourceRecipes();
const [loaded] = recipes.get(LIBVIPS) ?? [];
expect(loaded?.recipe.package).toMatchObject({
license: "LGPL-3.0-or-later",
target: "linux-x64-glibc",
version: "1.3.4",
});
// Prepared against exactly the reviewed supplement for these package bytes.
const supplementJson = await readFile(
path.join(DEFAULT_SUPPLEMENTS_DIRECTORY, LIBVIPS, "1.3.4", "supplement.json"),
);
expect(loaded?.recipe.supplement.sha256).toBe(
createHash("sha256").update(supplementJson).digest("hex"),
);
const supplement = (await loadLicenseSupplements())
.get(LIBVIPS)
?.find((candidate) => candidate.version === "1.3.4");
expect(loaded?.recipe.package.contentDigest).toBe(supplement?.contentDigest);
// And those are the bytes sharp 0.35.5 installs.
const root = await realpath(await installedPackageFile("sharp"));
const target = NATIVE_TARGETS.get("linux-x64-glibc");
if (target === undefined) throw new Error("target");
const installed = (await nativeClosure([{ name: "sharp", root }], target)).find(
(entry) => entry.name === LIBVIPS,
);
expect(installed && contentDigest(installed.files)).toBe(loaded?.recipe.package.contentDigest);
// Every component the supplement records inside the binary maps to a source component. The one
// exception is the package's own entry, which carries the standard texts of its declared licence.
const referenced = new Set(
(loaded?.recipe.components ?? []).flatMap((component) =>
component.licenseMaterial.map((material) => `${material.name} ${material.version}`),
),
);
const unmapped = (supplement?.components ?? [])
.map((component) => `${component.name} ${component.version}`)
.filter((component) => !referenced.has(component));
expect(unmapped).toEqual([`${LIBVIPS} 1.3.4`]);
const components = loaded?.recipe.components ?? [];
const byRole = (role: string) =>
components.filter((component) => component.role === role).length;
expect({
buildDefinition: byRole("build-definition"),
buildInput: byRole("build-input"),
compiled: byRole("compiled"),
}).toEqual({
buildDefinition: 1,
buildInput: 206,
compiled: 176,
});
// The five patches build/posix.sh applies, and the Rust standard library of the recorded nightly.
expect(
components.flatMap((component) => component.patches.map((patch) => patch.file)).sort(),
).toEqual([
"patches/glib-without-gregex.patch",
"patches/librsvg-9106011db93d701728ac2c9da50c9ab2c1bb5dc6.patch",
"patches/libultrahdr-pull-383.patch",
"patches/libvips-cpp-soversion.patch",
"patches/mozjpeg-f90668e0e4fb79c81e1f24a0ccc0e2090af761bf.patch",
]);
expect(
components.find((component) => component.name === "Rust standard library")?.sources[0]?.file,
).toBe("sources/rust/rust-src-nightly-2026-09-27.tar.xz");
// gvdb ships inside glib's archive; proxy-libintl is never built for this target.
expect(components.find((component) => component.name === "gvdb")?.includedIn).toEqual({
component: "glib",
path: "subprojects/gvdb",
});
expect(components.some((component) => component.name === "proxy-libintl")).toBe(false);
expect(loaded?.recipe.exclusions.map((exclusion) => exclusion.name)).toContain("proxy-libintl");
});
});
async function packNative(): Promise<string> {
const moduleDirectory = await nativeFixtureModule();
const outDirectory = await scratchDirectory("ekklesia-native-out-");
const result = await packModule({
licenseFile: path.join(repositoryRoot, "LICENSE"),
moduleDirectory,
nodeRange: ">=24.11.0 <25",
outDirectory,
sourceRevision: SOURCE_REVISION,
target: "linux-x64-glibc",
});
return result.archivePath;
}
describe.runIf(onTargetHost && cache !== undefined && existsSync(cache ?? ""))(
"the native proof's corresponding source, from the verified cache",
() => {
it("builds deterministically, is approved by the gate, and fails closed without it", {
timeout: 600_000,
}, async () => {
const first = await packNative();
const second = await packNative();
expect((await readFile(first)).equals(await readFile(second))).toBe(true);
const outA = await scratchDirectory("ekklesia-source-a-");
const outB = path.join(await scratchDirectory("ekklesia-unrelated-"), "nested", "out");
const a = await buildCorrespondingSource({
cacheDirectory: cache ?? "",
outDirectory: outA,
packageArchivePath: first,
});
const b = await buildCorrespondingSource({
cacheDirectory: cache ?? "",
outDirectory: outB,
packageArchivePath: second,
});
expect(a.required).toBe(true);
expect(a.sourceSha256).toBe(b.sourceSha256);
expect(
(await readFile(a.sourceArchivePath ?? "")).equals(
await readFile(b.sourceArchivePath ?? ""),
),
).toBe(true);
expect(path.basename(a.sourceArchivePath ?? "")).toBe(
"native-proof-0.1.0-linux-x64-glibc-corresponding-source.tar.gz",
);
// What it holds: every recorded source, as bytes, and nothing unrecorded.
const archive = await readFile(a.sourceArchivePath ?? "");
const entries = readTarGz(archive);
const top = "native-proof-0.1.0-linux-x64-glibc-corresponding-source";
const files = entries
.filter((entry) => !entry.directory)
.map((entry) => entry.path.slice(top.length + 1));
const manifest = JSON.parse(
entries
.find((entry) => entry.path === `${top}/corresponding-source.json`)
?.data.toString("utf8") ?? "{}",
);
const dependency = manifest.dependencies[0];
expect(dependency).toMatchObject({
name: LIBVIPS,
version: "1.3.4",
binaries: ["lib/libvips-cpp.so.8.18.7"],
});
const base = `dependencies/${LIBVIPS}/1.3.4`;
const sources = files.filter((file) => file.startsWith(`${base}/sources/`));
expect(sources.filter((file) => file.startsWith(`${base}/sources/crates/`))).toHaveLength(
353,
);
expect(
sources.filter((file) => /\/sources\/[^/]+\.tar\.(?:gz|xz)$/u.test(file)),
).toHaveLength(28);
expect(files.filter((file) => file.startsWith(`${base}/patches/`))).toHaveLength(5);
for (const required of [
"BUILDING.md",
"PROVENANCE.md",
"recipe.json",
"licenses/supplement.json",
"build/prepare-offline.sh",
"build/Cargo.lock",
"sources/rust/rust-src-nightly-2026-09-27.tar.xz",
]) {
expect(files, required).toContain(`${base}/${required}`);
}
expect(new Set(manifest.files.map((file: { path: string }) => file.path))).toEqual(
new Set(files.filter((file) => file !== "corresponding-source.json")),
);
// The binary-source relationship, by digest.
const metadata = JSON.parse(await readFile(a.metadataPath ?? "", "utf8"));
const packageSha256 = createHash("sha256")
.update(await readFile(first))
.digest("hex");
expect(metadata).toMatchObject({
correspondingSource: {
file: path.basename(a.sourceArchivePath ?? ""),
sha256: a.sourceSha256,
size: archive.length,
},
covered: [
{
contentDigest:
"ekklesia-content-v1:sha256:2927fe11493461fa34847191d0b9b1c6441007c3e99f306e5708cb07fb0a5e59",
name: LIBVIPS,
version: "1.3.4",
},
],
package: {
file: path.basename(first),
module: { id: "native-proof", version: "0.1.0" },
sha256: packageSha256,
target: "linux-x64-glibc",
},
});
expect(metadata.notice).toContain("GNU LGPL and MPL");
expect(metadata.notice).toContain(`SHA-256 ${a.sourceSha256}`);
// The gate approves exactly this, and fails closed when it is removed or corrupted.
await expect(
verifyRedistribution({
metadataPath: a.metadataPath ?? "",
packageArchivePath: first,
sourceArchivePath: a.sourceArchivePath ?? "",
}),
).resolves.toMatchObject({ approved: true, correspondingSource: { sha256: a.sourceSha256 } });
const missing = verifyRedistribution({
metadataPath: a.metadataPath ?? "",
packageArchivePath: first,
});
await expect(missing).rejects.toBeInstanceOf(CorrespondingSourceError);
await expect(missing).rejects.toMatchObject({
code: "CORRESPONDING_SOURCE_ARTIFACT_MISSING",
});
const corrupted = Buffer.from(archive);
const flip = Math.floor(corrupted.length / 3);
corrupted[flip] = (corrupted[flip] ?? 0) ^ 0x01;
await writeFile(b.sourceArchivePath ?? "", corrupted);
await expect(
verifyRedistribution({
metadataPath: b.metadataPath ?? "",
packageArchivePath: second,
sourceArchivePath: b.sourceArchivePath ?? "",
}),
).rejects.toMatchObject({
code: expect.stringMatching(
/^CORRESPONDING_SOURCE_(?:ARTIFACT_INVALID|MANIFEST_MISMATCH|METADATA_MISMATCH)$/u,
),
});
await rm(b.sourceArchivePath ?? "");
await expect(
verifyRedistribution({
metadataPath: b.metadataPath ?? "",
packageArchivePath: second,
sourceArchivePath: b.sourceArchivePath ?? "",
}),
).rejects.toMatchObject({ code: "CORRESPONDING_SOURCE_ARTIFACT_MISSING" });
});
it("leaves the binary package itself unchanged: it verifies, and sharp runs from it", {
timeout: 300_000,
}, async () => {
const archivePath = await packNative();
const archive = await readFile(archivePath);
const extractTo = path.join(
await scratchDirectory("ekklesia-native-installed-"),
"native-proof",
);
await expect(
verifyModulePackage({
archivePath,
expectedSha256: createHash("sha256").update(archive).digest("hex"),
extractTo,
platform: {
databaseRuntime: { "drizzle-orm": "0.45.2" },
moduleSdkVersion: "0.2.1",
platformVersion: "0.2.1",
},
}),
).resolves.toMatchObject({ moduleId: "native-proof", target: "linux-x64-glibc" });
// The source archive never travels inside the package.
expect(readTarGz(archive).some((entry) => /corresponding-source/u.test(entry.path))).toBe(
false,
);
const { stdout } = await execFileAsync(
process.execPath,
[
"--input-type=module",
"-e",
`const m = await import(${JSON.stringify(`file://${extractTo}/server/index.js`)}); process.stdout.write(JSON.stringify(await m.proof()));`,
],
{ cwd: tmpdir(), env: {} },
);
expect(JSON.parse(stdout)).toEqual({
pixels: [255, 0, 0, 255, 0, 0, 255, 0, 0, 255, 0, 0],
png: { format: "png", height: 3, width: 5 },
resized: { channels: 3, height: 2, width: 2 },
});
});
},
);
@@ -0,0 +1,774 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { gunzipSync, gzipSync } from "node:zlib";
import { afterEach, describe, expect, it } from "vitest";
import {
acquireCorrespondingSources,
buildCorrespondingSource,
CorrespondingSourceError,
type CorrespondingSourceErrorCode,
hasSourceObligations,
loadSourceRecipes,
verifyRedistribution,
} from "../src/corresponding-source.js";
import { contentDigest } from "../src/supplements.js";
import { deterministicTarGz, readTarGz } from "../src/tar.js";
import { cleanupFixtures, scratchDirectory } from "./support/fixture-module.js";
/**
* Corresponding-source artifacts and the public-distribution gate (ADR 0055), on small synthetic
* inputs: a fake LGPL native package, its reviewed supplement, its source recipe, and a source
* cache. The real libvips proof is `corresponding-source-native.test.ts`.
*/
afterEach(cleanupFixtures);
function sha256(data: string | Uint8Array): string {
return createHash("sha256").update(data).digest("hex");
}
const PACKAGE_FILE = "example-0.1.0-linux-x64-glibc.tar.gz";
const SOURCE_FILE = "example-0.1.0-linux-x64-glibc-corresponding-source.tar.gz";
const METADATA_FILE = "example-0.1.0-linux-x64-glibc.redistribution.json";
const TOP = "example-0.1.0-linux-x64-glibc-corresponding-source";
const SOURCES: Readonly<Record<string, string>> = {
"sources/fake-build-1.0.tar.gz": "build definitions for libfake\n",
"sources/fakelib-2.0.tar.gz": "fakelib 2.0 source\n",
"patches/fakelib-fix.patch": "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n",
"sources/helperlib-1.1.tar.gz": "helperlib 1.1 source\n",
"sources/codegen-0.3.crate": "codegen 0.3 build-only source\n",
};
interface FixtureOptions {
readonly version?: string;
readonly libraryBytes?: string;
readonly license?: string;
readonly notices?: string;
readonly recipe?: (recipe: Record<string, unknown>) => void;
readonly withRecipe?: boolean;
}
interface Fixture {
readonly root: string;
readonly packagePath: string;
readonly cache: string;
readonly recipesDirectory: string;
readonly supplementsDirectory: string;
}
function noticeEntry(name: string, version: string, licence: string, uses: string): string {
const rule = "-".repeat(80);
return `${rule}\n${name} ${version}\nLicense: ${licence}\nUsed in: ${uses}\n${rule}\n\n[LICENSE]\n\ntext\n`;
}
/** Writes a fake native package into a Module Package archive, its supplement, recipe, and cache. */
async function fixture(options: FixtureOptions = {}): Promise<Fixture> {
const version = options.version ?? "1.0.0";
const license = options.license ?? "LGPL-2.1-or-later";
const root = await scratchDirectory("ekklesia-corresponding-");
const nativeFiles = new Map<string, Buffer>([
["lib/libfake.so.1", Buffer.from(options.libraryBytes ?? `\u007fELF fake library ${version}`)],
["package.json", Buffer.from(JSON.stringify({ license, name: "fake-native", version }))],
]);
const digest = contentDigest(nativeFiles);
const packageFiles = new Map<string, Buffer>([
[
"ekklesia.package.json",
Buffer.from(
JSON.stringify({
module: { id: "example", version: "0.1.0" },
packageFormat: 1,
target: "linux-x64-glibc",
}),
),
],
[
"THIRD_PARTY_NOTICES",
Buffer.from(options.notices ?? noticeEntry("fake-native", version, license, "node_modules")),
],
...[...nativeFiles].map(([file, data]) => [`node_modules/fake-native/${file}`, data] as const),
]);
const packageDirectory = path.join(root, "package");
await mkdir(packageDirectory);
const packagePath = path.join(packageDirectory, PACKAGE_FILE);
await writeFile(packagePath, deterministicTarGz("example", packageFiles));
// The reviewed supplement for exactly these package bytes.
const supplementsDirectory = path.join(root, "supplements");
const supplementDirectory = path.join(supplementsDirectory, "fake-native", version);
await mkdir(path.join(supplementDirectory, "texts"), { recursive: true });
const material = async (content: string) => {
await writeFile(path.join(supplementDirectory, "texts", `${sha256(content)}.txt`), content);
return `texts/${sha256(content)}.txt`;
};
const supplement = {
supplementFormat: 1,
package: { name: "fake-native", version, license, contentDigest: digest },
statement: "Covers the libraries built into lib/libfake.so.1.",
components: [
{
name: "fakelib",
version: "2.0",
license: "LGPL-2.1-or-later",
source: "https://example.org/fakelib-2.0.tar.gz",
material: [
{ title: "COPYING", text: await material("GNU LESSER GENERAL PUBLIC LICENSE (fake)\n") },
],
},
{
name: "helperlib",
version: "1.1",
license: "MIT",
source: "https://example.org/helperlib-1.1.tar.gz",
material: [{ title: "LICENSE", text: await material("MIT License (fake)\n") }],
},
],
};
const supplementJson = `${JSON.stringify(supplement, null, 2)}\n`;
await writeFile(path.join(supplementDirectory, "supplement.json"), supplementJson);
await writeFile(path.join(supplementDirectory, "PROVENANCE.md"), "# How this was assembled\n");
// The recipe, and a cache holding the recorded bytes.
const recipesDirectory = path.join(root, "recipes");
const cache = path.join(root, "cache");
await mkdir(path.join(cache, "sha256"), { recursive: true });
const file = async (relative: string) => {
const content = SOURCES[relative] ?? "";
await writeFile(path.join(cache, "sha256", sha256(content)), content);
return {
file: relative,
origin: `https://example.org/${path.basename(relative)}`,
sha256: sha256(content),
size: Buffer.byteLength(content),
};
};
const building = "# Rebuilding libfake\n\nRun `make` in fake-build.\n";
const recipe: Record<string, unknown> = {
recipeFormat: 1,
package: {
name: "fake-native",
version,
license,
contentDigest: digest,
target: "linux-x64-glibc",
binaries: ["lib/libfake.so.1"],
},
supplement: { version, sha256: sha256(supplementJson) },
documents: [
{ path: "BUILDING.md", sha256: sha256(building), size: Buffer.byteLength(building) },
],
components: [
{
name: "fake-build",
version: "1.0",
role: "build-definition",
license: "MIT",
relationship: "The build definitions.",
licenseMaterial: [],
sources: [await file("sources/fake-build-1.0.tar.gz")],
patches: [],
},
{
name: "fakelib",
version: "2.0",
role: "compiled",
license: "LGPL-2.1-or-later",
relationship: "Compiled into lib/libfake.so.1.",
licenseMaterial: [{ name: "fakelib", version: "2.0" }],
sources: [await file("sources/fakelib-2.0.tar.gz")],
patches: [await file("patches/fakelib-fix.patch")],
},
{
name: "helperlib",
version: "1.1",
role: "compiled",
license: "MIT",
relationship: "Compiled into lib/libfake.so.1.",
licenseMaterial: [{ name: "helperlib", version: "1.1" }],
sources: [await file("sources/helperlib-1.1.tar.gz")],
patches: [],
},
{
name: "codegen",
version: "0.3",
role: "build-input",
license: "MIT",
relationship: "Needed only to build.",
licenseMaterial: [],
sources: [await file("sources/codegen-0.3.crate")],
patches: [],
},
],
exclusions: [{ name: "testlib", reason: "Used only by tests." }],
};
options.recipe?.(recipe);
if (options.withRecipe !== false) {
const recipeDirectory = path.join(recipesDirectory, "fake-native", version);
await mkdir(recipeDirectory, { recursive: true });
await writeFile(
path.join(recipeDirectory, "recipe.json"),
`${JSON.stringify(recipe, null, 2)}\n`,
);
await writeFile(path.join(recipeDirectory, "BUILDING.md"), building);
}
return { cache, packagePath, recipesDirectory, root, supplementsDirectory };
}
async function build(fx: Fixture, out = path.join(fx.root, "out")) {
return buildCorrespondingSource({
cacheDirectory: fx.cache,
outDirectory: out,
packageArchivePath: fx.packagePath,
recipesDirectory: fx.recipesDirectory,
supplementsDirectory: fx.supplementsDirectory,
});
}
function verify(fx: Fixture, source?: string, metadata?: string) {
return verifyRedistribution({
packageArchivePath: fx.packagePath,
recipesDirectory: fx.recipesDirectory,
supplementsDirectory: fx.supplementsDirectory,
...(source === undefined ? {} : { sourceArchivePath: source }),
...(metadata === undefined ? {} : { metadataPath: metadata }),
});
}
async function rejects(promise: Promise<unknown>, code: CorrespondingSourceErrorCode) {
const error = await promise.then(
() => undefined,
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(CorrespondingSourceError);
expect((error as CorrespondingSourceError).code).toBe(code);
return error as CorrespondingSourceError;
}
/** A built release, ready to tamper with: its source archive's files, and a way to write a changed
* archive (and metadata naming it) where the gate will look. */
async function release() {
const fx = await fixture();
const out = path.join(fx.root, "out");
await build(fx, out);
const sourcePath = path.join(out, SOURCE_FILE);
const metadataPath = path.join(out, METADATA_FILE);
const files = new Map<string, Buffer>();
for (const entry of readTarGz(await readFile(sourcePath))) {
if (!entry.directory) files.set(entry.path.slice(TOP.length + 1), entry.data);
}
/** Writes `changed` as the archive and keeps the metadata consistent with its bytes, so only the
* change itself can fail the gate. */
const write = async (changed: Map<string, Buffer>, archive?: Buffer) => {
const bytes = archive ?? deterministicTarGz(TOP, changed);
await writeFile(sourcePath, bytes);
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
const digest = sha256(bytes);
metadata.correspondingSource.sha256 = digest;
metadata.correspondingSource.size = bytes.length;
metadata.correspondingSource.manifestSha256 = sha256(
changed.get("corresponding-source.json") ?? "",
);
metadata.notice = metadata.notice.replace(/SHA-256 [0-9a-f]{64}/u, `SHA-256 ${digest}`);
await writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
};
/** Rewrites the manifest's file list to match `changed`, as a careful forger would. */
const relist = (changed: Map<string, Buffer>) => {
const manifest = JSON.parse(changed.get("corresponding-source.json")?.toString("utf8") ?? "{}");
manifest.files = [...changed]
.filter(([file]) => file !== "corresponding-source.json")
.map(([file, data]) => ({ path: file, size: data.length, sha256: sha256(data) }))
.sort((left: { path: string }, right: { path: string }) => (left.path < right.path ? -1 : 1));
changed.set("corresponding-source.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
return manifest;
};
return { files, fx, metadataPath, out, relist, sourcePath, write };
}
describe("building the corresponding-source artifact", () => {
it("writes a deterministic archive, its digest, the redistribution metadata, and the release notice", async () => {
const fx = await fixture();
const first = await build(fx, path.join(fx.root, "out-a"));
// A second build into an unrelated directory, from a moved cache and package copy.
const second = await build(
fx,
path.join(await scratchDirectory("ekklesia-elsewhere-"), "out-b"),
);
expect(first.required).toBe(true);
expect(first.sourceSha256).toBe(second.sourceSha256);
expect(await readFile(first.sourceArchivePath ?? "")).toEqual(
await readFile(second.sourceArchivePath ?? ""),
);
expect(await readFile(`${first.sourceArchivePath}.sha256`, "utf8")).toBe(
`${first.sourceSha256} ${SOURCE_FILE}\n`,
);
const entries = readTarGz(await readFile(first.sourceArchivePath ?? ""));
const files = entries
.filter((entry) => !entry.directory)
.map((entry) => entry.path.slice(TOP.length + 1));
expect(files).toEqual([
"README.md",
"SHA256SUMS",
"corresponding-source.json",
"dependencies/fake-native/1.0.0/BUILDING.md",
"dependencies/fake-native/1.0.0/PROVENANCE.md",
"dependencies/fake-native/1.0.0/licenses/supplement.json",
expect.stringMatching(
/^dependencies\/fake-native\/1\.0\.0\/licenses\/texts\/[0-9a-f]{64}\.txt$/u,
),
expect.stringMatching(
/^dependencies\/fake-native\/1\.0\.0\/licenses\/texts\/[0-9a-f]{64}\.txt$/u,
),
"dependencies/fake-native/1.0.0/patches/fakelib-fix.patch",
"dependencies/fake-native/1.0.0/recipe.json",
"dependencies/fake-native/1.0.0/sources/codegen-0.3.crate",
"dependencies/fake-native/1.0.0/sources/fake-build-1.0.tar.gz",
"dependencies/fake-native/1.0.0/sources/fakelib-2.0.tar.gz",
"dependencies/fake-native/1.0.0/sources/helperlib-1.1.tar.gz",
]);
// The actual bytes, not references to them.
const patch = entries.find((entry) => entry.path.endsWith("patches/fakelib-fix.patch"));
expect(patch?.data.toString("utf8")).toBe(SOURCES["patches/fakelib-fix.patch"]);
const manifest = JSON.parse(
entries
.find((entry) => entry.path.endsWith("/corresponding-source.json"))
?.data.toString("utf8") ?? "{}",
);
expect(manifest.module).toEqual({ id: "example", target: "linux-x64-glibc", version: "0.1.0" });
expect(manifest.dependencies[0]).toMatchObject({
binaries: ["lib/libfake.so.1"],
exclusions: [{ name: "testlib", reason: "Used only by tests." }],
name: "fake-native",
version: "1.0.0",
});
expect(
manifest.dependencies[0].components.map((component: { role: string }) => component.role),
).toEqual(["build-definition", "compiled", "compiled", "build-input"]);
const metadata = JSON.parse(await readFile(first.metadataPath ?? "", "utf8"));
expect(metadata).toMatchObject({
correspondingSource: {
file: SOURCE_FILE,
sha256: first.sourceSha256,
manifestSha256: first.manifestSha256,
},
covered: [{ name: "fake-native", version: "1.0.0" }],
package: { file: PACKAGE_FILE, sha256: sha256(await readFile(fx.packagePath)) },
});
const notice = await readFile(first.noticePath ?? "", "utf8");
expect(notice).toBe(metadata.notice);
expect(notice).toContain(
"This release includes third-party software covered by licenses including the GNU LGPL",
);
expect(notice).toContain(
"Corresponding source and build/relink materials for the covered native dependencies",
);
expect(notice).toContain(SOURCE_FILE);
expect(notice).toContain(`SHA-256 ${first.sourceSha256}`);
// Nothing machine-specific in what the build generated.
for (const generated of ["README.md", "SHA256SUMS", "corresponding-source.json"]) {
const content =
entries.find((entry) => entry.path === `${TOP}/${generated}`)?.data.toString("utf8") ?? "";
expect(content).not.toContain(fx.root);
expect(content).not.toContain(fx.cache);
}
expect(JSON.stringify(metadata)).not.toContain(fx.root);
});
it("is approved by the gate exactly as built", async () => {
const { fx, metadataPath, sourcePath } = await release();
await expect(verify(fx, sourcePath, metadataPath)).resolves.toMatchObject({
approved: true,
correspondingSource: { file: SOURCE_FILE },
covered: [{ name: "fake-native", version: "1.0.0" }],
});
});
it("refuses to build from cached bytes that differ from the recipe, and writes nothing", async () => {
const fx = await fixture();
await writeFile(
path.join(fx.cache, "sha256", sha256(SOURCES["patches/fakelib-fix.patch"] ?? "")),
"changed patch\n",
);
await rejects(build(fx), "CORRESPONDING_SOURCE_SOURCE_MISMATCH");
await rm(path.join(fx.cache, "sha256", sha256(SOURCES["sources/fakelib-2.0.tar.gz"] ?? "")));
await expect(readFile(path.join(fx.root, "out", SOURCE_FILE))).rejects.toThrow();
});
it("refuses a missing cached file, and never overwrites an existing release", async () => {
const fx = await fixture();
await rm(path.join(fx.cache, "sha256", sha256(SOURCES["sources/helperlib-1.1.tar.gz"] ?? "")));
await rejects(build(fx), "CORRESPONDING_SOURCE_SOURCE_MISSING");
const fresh = await fixture();
await build(fresh);
await rejects(build(fresh), "CORRESPONDING_SOURCE_OUTPUT_EXISTS");
});
it("builds nothing for a package with no covered native code, and the gate approves it without an artifact", async () => {
const fx = await fixture({ license: "MIT", withRecipe: false });
// The fixture's supplement records an LGPL component, so remove it: a package whose own files and
// licence carry no source obligation needs no corresponding source.
await rm(fx.supplementsDirectory, { force: true, recursive: true });
await expect(build(fx)).resolves.toEqual({ required: false });
await expect(verify(fx)).resolves.toMatchObject({ approved: true, covered: [] });
});
it("treats a dependency as covered when its reviewed supplement records a copyleft component", async () => {
// Declared MIT, but the supplement says an LGPL library is built into it.
const fx = await fixture({ license: "MIT", withRecipe: false });
// The supplement's declared licence must match the package's for the supplement to apply.
const supplementPath = path.join(
fx.supplementsDirectory,
"fake-native",
"1.0.0",
"supplement.json",
);
const supplement = JSON.parse(await readFile(supplementPath, "utf8"));
expect(supplement.package.license).toBe("MIT");
await rejects(build(fx), "CORRESPONDING_SOURCE_RECIPE_MISSING");
});
});
describe("the public-distribution gate fails closed", () => {
it("without the corresponding-source archive, or without the redistribution metadata", async () => {
const { fx, metadataPath, sourcePath } = await release();
await rejects(verify(fx), "CORRESPONDING_SOURCE_ARTIFACT_MISSING");
await rm(sourcePath);
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_ARTIFACT_MISSING");
const second = await release();
await rejects(verify(second.fx, second.sourcePath), "CORRESPONDING_SOURCE_METADATA_MISMATCH");
});
it("when the metadata names a different source digest, or the archive's bytes are corrupted", async () => {
const { fx, metadataPath, sourcePath } = await release();
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
metadata.correspondingSource.sha256 = "0".repeat(64);
await writeFile(metadataPath, JSON.stringify(metadata));
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_METADATA_MISMATCH");
const corrupt = await release();
const bytes = await readFile(corrupt.sourcePath);
const middle = Math.floor(bytes.length / 2);
bytes[middle] = (bytes[middle] ?? 0) ^ 0xff;
await writeFile(corrupt.sourcePath, bytes);
await rejects(
verify(corrupt.fx, corrupt.sourcePath, corrupt.metadataPath),
"CORRESPONDING_SOURCE_ARTIFACT_INVALID",
);
});
it("when the release notice does not name the exact artifact", async () => {
const { fx, metadataPath, sourcePath } = await release();
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
metadata.notice = metadata.notice.replace(SOURCE_FILE, "some-other-source.tar.gz");
await writeFile(metadataPath, JSON.stringify(metadata));
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_METADATA_MISMATCH");
});
it("when the native package's bytes are not those the recipe was prepared for", async () => {
const built = await release();
// The same name and version, different library bytes: a different content digest.
const other = await fixture({ libraryBytes: "\u007fELF a different build" });
await writeFile(built.fx.packagePath, await readFile(other.packagePath));
await rejects(
verify(built.fx, built.sourcePath, built.metadataPath),
"CORRESPONDING_SOURCE_RECIPE_MISMATCH",
);
});
it("when a source component's version differs from the recipe", async () => {
const { files, fx, metadataPath, relist, sourcePath, write } = await release();
const manifest = JSON.parse(files.get("corresponding-source.json")?.toString("utf8") ?? "{}");
manifest.dependencies[0].components[1].version = "2.1";
files.set("corresponding-source.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
relist(files);
await write(files);
const error = await rejects(
verify(fx, sourcePath, metadataPath),
"CORRESPONDING_SOURCE_MANIFEST_MISMATCH",
);
expect(error.subject).toBe("[email protected]");
});
it("when a required source component is missing", async () => {
const { files, fx, metadataPath, relist, sourcePath, write } = await release();
const manifest = JSON.parse(files.get("corresponding-source.json")?.toString("utf8") ?? "{}");
manifest.dependencies[0].components.splice(2, 1);
files.set("corresponding-source.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
files.delete("dependencies/fake-native/1.0.0/sources/helperlib-1.1.tar.gz");
relist(files);
await write(files);
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_MANIFEST_MISMATCH");
});
it("when a patch's bytes changed, even with the file list rewritten to match", async () => {
const { files, fx, metadataPath, relist, sourcePath, write } = await release();
files.set(
"dependencies/fake-native/1.0.0/patches/fakelib-fix.patch",
Buffer.from("a different patch\n"),
);
relist(files);
await write(files);
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_MANIFEST_MISMATCH");
});
it("when the manifest records a file the archive does not hold", async () => {
const { files, fx, metadataPath, sourcePath, write } = await release();
files.delete("dependencies/fake-native/1.0.0/sources/fakelib-2.0.tar.gz");
await write(files);
const error = await rejects(
verify(fx, sourcePath, metadataPath),
"CORRESPONDING_SOURCE_MANIFEST_MISMATCH",
);
expect(error.subject).toBe("dependencies/fake-native/1.0.0/sources/fakelib-2.0.tar.gz");
});
it("when the archive holds source material its manifest does not record", async () => {
const { files, fx, metadataPath, sourcePath, write } = await release();
files.set(
"dependencies/fake-native/1.0.0/sources/unreviewed.tar.gz",
Buffer.from("not recorded\n"),
);
await write(files);
const error = await rejects(
verify(fx, sourcePath, metadataPath),
"CORRESPONDING_SOURCE_UNRECORDED_FILE",
);
expect(error.subject).toBe("dependencies/fake-native/1.0.0/sources/unreviewed.tar.gz");
});
it("when a generated file names a machine-specific path", async () => {
const { files, fx, metadataPath, relist, sourcePath, write } = await release();
files.set("README.md", Buffer.from("Built from /home/builder/cache/sha256.\n"));
relist(files);
await write(files);
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_LOCAL_PATH");
});
it("when the archive's entries are not in the deterministic order", async () => {
const { files, fx, metadataPath, sourcePath, write } = await release();
// The same entries and bytes, with the last two file entries swapped.
const raw = gunzipSync(await readFile(sourcePath));
const chunks: Buffer[] = [];
let offset = 0;
while (offset < raw.length && !raw.subarray(offset, offset + 512).every((byte) => byte === 0)) {
const size = Number.parseInt(
raw
.subarray(offset + 124, offset + 136)
.toString("latin1")
.replace(/\0.*$/u, "")
.trim() || "0",
8,
);
const length = 512 + Math.ceil(size / 512) * 512;
chunks.push(raw.subarray(offset, offset + length));
offset += length;
}
const last = chunks.length - 1;
[chunks[last - 1], chunks[last]] = [chunks[last] as Buffer, chunks[last - 1] as Buffer];
await write(files, gzipSync(Buffer.concat([...chunks, Buffer.alloc(1024)]), { level: 9 }));
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_ARTIFACT_INVALID");
});
it("when the artifact was built for another version of the native dependency", async () => {
// The 1.3.3-for-1.3.4 case: an artifact for fake-native 1.0.0 beside a package with 1.0.1.
const old = await release();
const current = await fixture({ version: "1.0.1" });
await mkdir(path.join(current.recipesDirectory, "fake-native", "1.0.0"), { recursive: true });
for (const file of ["recipe.json", "BUILDING.md"]) {
await writeFile(
path.join(current.recipesDirectory, "fake-native", "1.0.0", file),
await readFile(path.join(old.fx.recipesDirectory, "fake-native", "1.0.0", file)),
);
}
await rejects(
verify(current, old.sourcePath, old.metadataPath),
"CORRESPONDING_SOURCE_MANIFEST_MISMATCH",
);
});
it("when the package bundles copyleft code, which this mechanism does not cover", async () => {
const notices =
noticeEntry("fake-native", "1.0.0", "LGPL-2.1-or-later", "node_modules") +
noticeEntry("bundled-lgpl", "3.0.0", "LGPL-3.0-only", "backend");
const fx = await fixture({ notices });
await rejects(build(fx), "CORRESPONDING_SOURCE_BUNDLED_COPYLEFT");
await rejects(verify(fx), "CORRESPONDING_SOURCE_BUNDLED_COPYLEFT");
});
it("when a covered dependency has no recipe, or its recipe was prepared against another supplement", async () => {
await rejects(
build(await fixture({ withRecipe: false })),
"CORRESPONDING_SOURCE_RECIPE_MISSING",
);
const fx = await fixture({
recipe: (recipe) => {
(recipe.supplement as { sha256: string }).sha256 = "f".repeat(64);
},
});
await rejects(build(fx), "CORRESPONDING_SOURCE_RECIPE_MISMATCH");
const unknownMaterial = await fixture({
recipe: (recipe) => {
const components = recipe.components as {
licenseMaterial: { name: string; version: string }[];
}[];
(components[1] as { licenseMaterial: unknown[] }).licenseMaterial = [
{ name: "fakelib", version: "9.9" },
];
},
});
await rejects(build(unknownMaterial), "CORRESPONDING_SOURCE_RECIPE_MISMATCH");
});
});
describe("source recipes are reviewed inputs", () => {
async function loadWith(change: (recipe: Record<string, unknown>) => void) {
const fx = await fixture({ recipe: change });
return loadSourceRecipes(fx.recipesDirectory);
}
it("refuses an absolute or climbing source path, a non-https origin, or a compiled component without licence material", async () => {
const component = (recipe: Record<string, unknown>, index: number) =>
(recipe.components as Record<string, unknown>[])[index] as Record<string, unknown>;
const first = (recipe: Record<string, unknown>) =>
(component(recipe, 1).sources as Record<string, unknown>[])[0] as Record<string, unknown>;
for (const change of [
(recipe: Record<string, unknown>) => {
first(recipe).file = "/home/builder/fakelib-2.0.tar.gz";
},
(recipe: Record<string, unknown>) => {
first(recipe).file = "sources/../../fakelib-2.0.tar.gz";
},
(recipe: Record<string, unknown>) => {
first(recipe).origin = "http://example.org/fakelib-2.0.tar.gz";
},
(recipe: Record<string, unknown>) => {
component(recipe, 1).licenseMaterial = [];
},
(recipe: Record<string, unknown>) => {
component(recipe, 2).version = "2.0";
component(recipe, 2).name = "fakelib";
},
]) {
await rejects(loadWith(change), "CORRESPONDING_SOURCE_RECIPE_INVALID");
}
});
it("refuses a document whose bytes changed, and an unlisted file beside the recipe", async () => {
const fx = await fixture();
const directory = path.join(fx.recipesDirectory, "fake-native", "1.0.0");
await writeFile(path.join(directory, "BUILDING.md"), "edited without review\n");
await rejects(loadSourceRecipes(fx.recipesDirectory), "CORRESPONDING_SOURCE_RECIPE_INVALID");
const second = await fixture();
await writeFile(
path.join(second.recipesDirectory, "fake-native", "1.0.0", "notes.txt"),
"stray\n",
);
await rejects(
loadSourceRecipes(second.recipesDirectory),
"CORRESPONDING_SOURCE_RECIPE_INVALID",
);
});
it("recognizes licences with source obligations, by SPDX identifier or by upstream name", () => {
for (const licence of [
"LGPL-3.0-or-later",
"MPL-2.0",
"GPL-2.0-only",
"Mozilla Public License 1.1",
"MIT AND LGPL-2.1-or-later",
"EPL-2.0",
]) {
expect(hasSourceObligations(licence), licence).toBe(true);
}
for (const licence of [
"MIT",
"Apache-2.0",
"BSD-3-Clause",
"ISC",
"Zlib",
"MIT OR Apache-2.0",
]) {
expect(hasSourceObligations(licence), licence).toBe(false);
}
});
});
describe("acquisition is separate, verified, and the only network step", () => {
it("fetches each recorded file once, verifies it, and refuses a response that differs", async () => {
const fx = await fixture();
const fetched: string[] = [];
const served = new Map(
Object.entries(SOURCES).map(([file, content]) => [
`https://example.org/${path.basename(file)}`,
content,
]),
);
const empty = path.join(fx.root, "fresh-cache");
const result = await acquireCorrespondingSources({
cacheDirectory: empty,
fetch: async (url) => {
fetched.push(url);
return Buffer.from(served.get(url) ?? "");
},
packageArchivePath: fx.packagePath,
recipesDirectory: fx.recipesDirectory,
supplementsDirectory: fx.supplementsDirectory,
});
expect(result).toMatchObject({ fetched: 5, files: 5 });
// A second run finds everything verified and fetches nothing.
const again = await acquireCorrespondingSources({
cacheDirectory: empty,
fetch: async () => {
throw new Error("no network expected");
},
recipes: [{ name: "fake-native", version: "1.0.0" }],
recipesDirectory: fx.recipesDirectory,
});
expect(again).toMatchObject({ fetched: 0, files: 5 });
// The built artifact from the acquired cache is identical to one built from the original cache.
const fromAcquired = await buildCorrespondingSource({
cacheDirectory: empty,
outDirectory: path.join(fx.root, "acquired-out"),
packageArchivePath: fx.packagePath,
recipesDirectory: fx.recipesDirectory,
supplementsDirectory: fx.supplementsDirectory,
});
expect(fromAcquired.sourceSha256).toBe((await build(fx)).sourceSha256);
// A transient upstream failure is retried; only the recorded bytes are ever stored.
const flaky = path.join(fx.root, "flaky-cache");
const attempts = new Map<string, number>();
await expect(
acquireCorrespondingSources({
cacheDirectory: flaky,
fetch: async (url) => {
const count = (attempts.get(url) ?? 0) + 1;
attempts.set(url, count);
if (count === 1) {
throw new Error("connection reset");
}
return Buffer.from(served.get(url) ?? "");
},
recipes: [{ name: "fake-native", version: "1.0.0" }],
recipesDirectory: fx.recipesDirectory,
}),
).resolves.toMatchObject({ fetched: 5 });
const tampered = path.join(fx.root, "tampered-cache");
await rejects(
acquireCorrespondingSources({
cacheDirectory: tampered,
fetch: async (url) => Buffer.from(`${served.get(url) ?? ""}upstream changed\n`),
recipes: [{ name: "fake-native", version: "1.0.0" }],
recipesDirectory: fx.recipesDirectory,
}),
"CORRESPONDING_SOURCE_ACQUISITION_FAILED",
);
const { readdir } = await import("node:fs/promises");
expect(await readdir(path.join(tampered, "sha256"))).toEqual([]);
});
});
@@ -0,0 +1,60 @@
#!/usr/bin/env node
// Builds the repository's real sharp proof as a native Module Package for the hardened verifier
// container test. Run inside deploy/Dockerfile's build stage, where the workspace is already built
// and has exactly the locked dependencies the runtime image was built against.
import { cp, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { packModule } from "@ekklesia/module-pack";
const repositoryRoot = path.resolve(import.meta.dirname, "..", "..", "..", "..");
const outDirectory = process.argv[2];
if (outDirectory === undefined || !path.isAbsolute(outDirectory)) {
throw new Error("An absolute output directory is required.");
}
const temporaryRoot = await mkdtemp(path.join(tmpdir(), "ekklesia-native-verifier-proof-"));
try {
const moduleDirectory = path.join(temporaryRoot, "native-proof");
await cp(
path.join(repositoryRoot, "packages", "module-pack", "tests", "fixtures", "native-module"),
moduleDirectory,
{ recursive: true },
);
const packageFile = path.join(moduleDirectory, "package.json");
const packageJson = JSON.parse(await readFile(packageFile, "utf8"));
packageJson.engines = { node: ">=24.11.0 <25" };
await writeFile(packageFile, `${JSON.stringify(packageJson, null, 2)}\n`);
const modulesDirectory = path.join(moduleDirectory, "node_modules");
await mkdir(path.join(modulesDirectory, "@ekklesia"), { recursive: true });
await symlink(
await realpath(path.join(repositoryRoot, "packages", "module-pack", "node_modules", "sharp")),
path.join(modulesDirectory, "sharp"),
);
await symlink(
await realpath(
path.join(
repositoryRoot,
"packages",
"module-pack",
"node_modules",
"@ekklesia",
"module-sdk",
),
),
path.join(modulesDirectory, "@ekklesia", "module-sdk"),
);
const result = await packModule({
licenseFile: path.join(repositoryRoot, "LICENSE"),
moduleDirectory,
outDirectory,
sourceRevision: "0".repeat(40),
target: "linux-x64-glibc",
});
process.stdout.write(`${path.basename(result.archivePath)}\n`);
} finally {
await rm(temporaryRoot, { force: true, recursive: true });
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/module-sdk", "name": "@ekklesia/module-sdk",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+1
View File
@@ -37,6 +37,7 @@ export {
isDevicePrincipal, isDevicePrincipal,
isModuleHttpMethod, isModuleHttpMethod,
isModuleRouteBodyMode, isModuleRouteBodyMode,
isModuleStorageError,
isValidModuleStorageKey, isValidModuleStorageKey,
isValidModuleStorageReadUrlFilename, isValidModuleStorageReadUrlFilename,
MODULE_HTTP_BODY_MODES, MODULE_HTTP_BODY_MODES,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/module-ui-build", "name": "@ekklesia/module-ui-build",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/module-ui-host", "name": "@ekklesia/module-ui-host",
"version": "0.2.1", "version": "0.2.5",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/module-ui-sdk", "name": "@ekklesia/module-ui-sdk",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/typescript-config", "name": "@ekklesia/typescript-config",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"files": [ "files": [
"base.json", "base.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/ui", "name": "@ekklesia/ui",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+67 -1
View File
@@ -6,7 +6,9 @@
# - its module storage mount point, /var/lib/ekklesia/modules, exists, is root-owned, and is empty; # - its module storage mount point, /var/lib/ekklesia/modules, exists, is root-owned, and is empty;
# - EKKLESIA_MODULES_DIR names that directory, in the image configuration and in a running process; # - EKKLESIA_MODULES_DIR names that directory, in the image configuration and in a running process;
# - a checkout with a populated modules/ directory builds the same image: the runtime file system # - a checkout with a populated modules/ directory builds the same image: the runtime file system
# of both builds is identical, and no module from that directory enters it. # of both builds is identical, and no module from that directory enters it;
# - the hardened verifier keeps /tmp noexec while a dedicated bounded execution scratch loads and
# runs the real sharp 0.35.5/libvips 1.3.4 native proof package.
# #
# bash scripts/check-image.sh builds both images from this checkout # bash scripts/check-image.sh builds both images from this checkout
# EKKLESIA_CHECK_IMAGE=<image> bash scripts/check-image.sh checks one existing image's contents # EKKLESIA_CHECK_IMAGE=<image> bash scripts/check-image.sh checks one existing image's contents
@@ -103,6 +105,7 @@ context_copy() {
tag_base="ekklesia-ci:${GITHUB_SHA:-local}-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-" tag_base="ekklesia-ci:${GITHUB_SHA:-local}-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-"
generic_image="${tag_base}generic-$$" generic_image="${tag_base}generic-$$"
populated_image="${tag_base}populated-$$" populated_image="${tag_base}populated-$$"
builder_image="${tag_base}native-proof-builder-$$"
# build_image <context> <tag> # build_image <context> <tag>
build_image() { build_image() {
@@ -143,4 +146,67 @@ else
echo "note the two builds' layers differ (a cold build cache); their file systems do not" echo "note the two builds' layers differ (a cold build cache); their file systems do not"
fi fi
step "Proving native package verification with /tmp noexec and a dedicated execution scratch"
built_images+=("$builder_image")
DOCKER_BUILDKIT=1 docker build --quiet --file "$work/generic/deploy/Dockerfile" \
--target build --label "${EKKLESIA_CI_LABEL}=${project}" \
--label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
--tag "$builder_image" "$work/generic" >/dev/null || fail "the native proof builder did not build."
mkdir "$work/native-package"
package_archive="$({
docker run --rm --network none --user "$(id -u):$(id -g)" \
-v "$work/native-package:/proof" --entrypoint node "$builder_image" \
packages/module-pack/tests/support/pack-native-verifier-proof.mjs /proof
} | tail -1)" || fail "the real native proof package did not build."
[[ "$package_archive" == native-proof-0.1.0-linux-x64-glibc.tar.gz ]] ||
fail "the native proof builder returned an unexpected archive."
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 "PROOF_ARCHIVE=$package_archive" -v "$work/native-package:/package:ro" \
--entrypoint sh "$generic_image" -ec '
fail() { echo "check-image: $*" >&2; exit 1; }
verifier() {
EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
node core/dist/cli/verify-module-package.js "/package/$PROOF_ARCHIVE" \
--sha256-file "/package/$PROOF_ARCHIVE.sha256" "$@"
}
scratch_failure() {
if output="$(EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR="$1" \
node core/dist/cli/verify-module-package.js "/package/$PROOF_ARCHIVE" \
--sha256-file "/package/$PROOF_ARCHIVE.sha256" 2>&1)"; then
fail "the verifier accepted unusable execution scratch $1."
fi
printf "%s\n" "$output" | grep -Fq "[MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE]" ||
fail "the verifier did not identify unusable execution scratch $1."
}
grep -Eq " /tmp .*noexec" /proc/mounts || fail "/tmp is not mounted noexec."
verifier --extract-to /tmp/noexec-installed >/tmp/noexec-verify.log
if env -i /usr/local/bin/node --input-type=module --eval \
"await import(\"file:///tmp/noexec-installed/server/index.js\");" \
>/tmp/noexec-load.log 2>&1; then
fail "the native addon unexpectedly loaded from noexec /tmp."
fi
rm -rf /tmp/noexec-installed
scratch_failure /tmp
scratch_failure /missing-verifier-scratch
scratch_failure /package
verifier --extract-to /run/ekklesia-verifier/installed >/tmp/native-verify.log
env -i /usr/local/bin/node --input-type=module --eval \
"const m=await import(\"file:///run/ekklesia-verifier/installed/server/index.js\"); const actual=await m.proof(); const expected={pixels:[255,0,0,255,0,0,255,0,0,255,0,0],png:{format:\"png\",height:3,width:5},resized:{channels:3,height:2,width:2}}; if(JSON.stringify(actual)!==JSON.stringify(expected)) throw new Error(\"sharp proof mismatch\");"
rm -rf /run/ekklesia-verifier/installed
verifier >/tmp/native-verify-a.log 2>&1 & first=$!
verifier >/tmp/native-verify-b.log 2>&1 & second=$!
wait "$first" || { cat /tmp/native-verify-a.log >&2; fail "first concurrent verification failed."; }
wait "$second" || { cat /tmp/native-verify-b.log >&2; fail "second concurrent verification failed."; }
[ -z "$(find /run/ekklesia-verifier -mindepth 1 -print -quit)" ] ||
fail "the verifier left execution scratch behind."
' || fail "the hardened native verifier proof failed."
echo "ok /tmp stayed noexec; native verification, sharp execution, rejection, concurrency, and cleanup passed"
echo "Generic platform image check passed." echo "Generic platform image check passed."
+14 -2
View File
@@ -5,6 +5,11 @@
# bash scripts/ci-sweep.sh --stale [--max-age-seconds N] any CI stack older than N (default 3 h) # bash scripts/ci-sweep.sh --stale [--max-age-seconds N] any CI stack older than N (default 3 h)
# bash scripts/ci-sweep.sh --job this job's own stacks, at any age # bash scripts/ci-sweep.sh --job this job's own stacks, at any age
# #
# `--stale --prefix P` narrows the sweep to stacks whose names begin with P, which must itself begin
# with the CI prefix: it can only ever narrow a sweep, never widen one. scripts/test-ci-sweep.sh
# confines its short thresholds with it, because the daemon is shared and another job's live stack
# may be on it right now.
#
# Every CI script already removes its own stack in an EXIT trap, but a trap never runs when the runner # Every CI script already removes its own stack in an EXIT trap, but a trap never runs when the runner
# kills the job container: a cancelled or superseded job (the server honours `concurrency`), or # kills the job container: a cancelled or superseded job (the server honours `concurrency`), or
# the runner's own time limit. So each workflow job that starts stacks ends with `--job` in an # the runner's own time limit. So each workflow job that starts stacks ends with `--job` in an
@@ -31,9 +36,10 @@ source "$repository_root/scripts/lib/ci-project.sh"
compose_label="com.docker.compose.project" compose_label="com.docker.compose.project"
mode="" mode=""
max_age_seconds="${EKKLESIA_CI_SWEEP_MAX_AGE_SECONDS:-10800}" max_age_seconds="${EKKLESIA_CI_SWEEP_MAX_AGE_SECONDS:-10800}"
narrow_prefix=""
usage() { usage() {
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] | --job" >&2 echo "usage: ci-sweep.sh --stale [--max-age-seconds N] [--prefix P] | --job" >&2
exit 2 exit 2
} }
@@ -48,11 +54,17 @@ while (($# > 0)); do
max_age_seconds="$2" max_age_seconds="$2"
shift shift
;; ;;
--prefix)
[[ "${2:-}" == "$EKKLESIA_CI_PREFIX"?* ]] || usage
narrow_prefix="$2"
shift
;;
*) usage ;; *) usage ;;
esac esac
shift shift
done done
[[ -n "$mode" ]] || usage [[ -n "$mode" ]] || usage
[[ -z "$narrow_prefix" || "$mode" == stale ]] || usage
if [[ "$mode" == job ]]; then if [[ "$mode" == job ]]; then
[[ -n "${GITHUB_RUN_ID:-}" ]] || { [[ -n "${GITHUB_RUN_ID:-}" ]] || {
@@ -62,7 +74,7 @@ if [[ "$mode" == job ]]; then
prefix="$(ci_job_prefix)" prefix="$(ci_job_prefix)"
cutoff="" cutoff=""
else else
prefix="$EKKLESIA_CI_PREFIX" prefix="${narrow_prefix:-$EKKLESIA_CI_PREFIX}"
cutoff="$(($(date +%s) - max_age_seconds))" cutoff="$(($(date +%s) - max_age_seconds))"
fi fi
+17 -1
View File
@@ -187,7 +187,9 @@ check(
); );
// --- A module UI, imported from the API's origin ------------------------------------------------- // --- A module UI, imported from the API's origin -------------------------------------------------
const moduleButton = "document.querySelector('[data-testid^=\"select-\"]')"; // The first module link — never the Overview, which is the Dashboard's own landing page (ADR 0057).
const moduleButton =
'document.querySelector(\'[data-testid^="select-"]:not([data-testid="select-overview"])\')';
const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation"); const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
if (hasModule) { if (hasModule) {
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`); const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
@@ -224,6 +226,20 @@ if (hasModule) {
streams.every((response) => response.status === 200), streams.every((response) => response.status === 200),
`every realtime stream it opened was accepted (${streams.length} opened)`, `every realtime stream it opened was accepted (${streams.length} opened)`,
); );
// A refresh stays on the module's own page: the proxy forwards the deep link, the API serves the
// Dashboard page there, and the shell mounts the same module again (ADR 0057).
const moduleUrl = await evaluate("location.href");
check(
moduleUrl === `${dashboardOrigin}/${moduleId}`,
"opening the module put its own address in the address bar",
);
await navigate(moduleUrl);
const remounted = await waitFor(
`location.href === ${JSON.stringify(moduleUrl)} && ${selector(`select-${moduleId}`)}?.getAttribute("aria-current") === "page" && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
"the module UI to mount again after a refresh",
);
check(remounted, `refreshing ${moduleUrl} reopens the ${moduleId} module`);
} }
// A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a // A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a
// module unmounting, a navigation) is not. // module unmounting, a navigation) is not.
+70 -8
View File
@@ -35,6 +35,10 @@ function required(name) {
return value; return value;
} }
function optional(name) {
return process.env[name] || "";
}
let failures = 0; let failures = 0;
function check(condition, description) { function check(condition, description) {
@@ -84,6 +88,27 @@ async function edgeChecks() {
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)", "the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
); );
// Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). No module
// is installed yet at this point; a module's own address is reloaded in a real browser later.
for (const deepLink of ["/settings", "/settings/displays/add", "/account"]) {
const deepPage = await fetch(`${dashboardOrigin}${deepLink}`);
check(
deepPage.status === 200 &&
deepPage.headers.get("etag") === page.headers.get("etag") &&
(await deepPage.text()) === html,
`GET ${deepLink} through the proxy is the same Dashboard page`,
);
}
// A top-level name shaped like a module id reaches the API, which knows the installed modules and
// answers anything that is not a Dashboard page with its sanitized 404 — never the page.
for (const unknown of ["/definitely-not-a-real-module", "/settings-old", "/modules/example"]) {
const response = await fetch(`${dashboardOrigin}${unknown}`);
check(
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
`GET ${unknown} through the proxy is the API's sanitized 404, not a Dashboard page`,
);
}
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`); const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
await script.arrayBuffer(); await script.arrayBuffer();
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200"); check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
@@ -148,6 +173,10 @@ async function splitRoutingChecks() {
"the Dashboard hostname refuses /display/*", "the Dashboard hostname refuses /display/*",
); );
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /"); check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /");
check(
await refusedByProxy(`${apiOrigin}/settings/displays`),
"the API hostname serves no Dashboard page either",
);
check( check(
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`), await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
"the API hostname refuses the Dashboard's script", "the API hostname refuses the Dashboard's script",
@@ -462,37 +491,70 @@ async function ownerCookie() {
} }
/** /**
* The installed-module checks (ADR 0048): whether `example` is in the module inventory, and what * The installed-module checks (ADR 0048): whether the selected module is in the UI inventory and,
* its own route returns. Absent, it is in no inventory and its routes are not mounted at all; * when one is supplied, whether its module-owned probe route responds. Core's own `example` fixture
* present, its records route returns exactly the expected values, which proves its tables, its * retains its stronger records assertion, which also proves its database facade.
* dependencies, and the platform's database facade all work from module storage.
*/ */
async function moduleChecks() { async function moduleChecks() {
const expectation = required("SMOKE_MODULE_EXPECT"); const expectation = required("SMOKE_MODULE_EXPECT");
const moduleId = optional("SMOKE_MODULE_ID") || "example";
const probePath = optional("SMOKE_MODULE_PROBE_PATH");
const cookie = await ownerCookie(); const cookie = await ownerCookie();
const headers = { cookie, origin: dashboardOrigin }; const headers = { cookie, origin: dashboardOrigin };
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers }); const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
const inventoryBody = await inventory.json(); const inventoryBody = await inventory.json();
const installed = const installed =
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === "example"); inventory.status === 200 && inventoryBody.modules.some((module) => module.id === moduleId);
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
if (expectation === "absent") { if (expectation === "absent") {
check(inventory.status === 200 && !installed, "the module inventory has no `example` module"); check(
inventory.status === 200 && !installed,
`the module inventory has no \`${moduleId}\` module`,
);
} else {
check(installed, `the module inventory lists the \`${moduleId}\` module`);
}
// The module's own Dashboard address (ADR 0057) exists exactly while the module is installed with a
// Dashboard UI — disabled or not: the Dashboard page when it is, the API's sanitized 404 when it is
// not. A disabled module is not running, but its address still opens the Dashboard.
const pageExpectation =
optional("SMOKE_MODULE_PAGE") || (expectation === "absent" ? "absent" : "served");
const modulePage = await fetch(`${dashboardOrigin}/${moduleId}`);
const modulePageBody = await modulePage.text();
if (pageExpectation === "absent") {
check(
modulePage.status === 404 && modulePageBody === '{"code":"NOT_FOUND"}',
`GET /${moduleId} is the sanitized 404 while the module is not installed`,
);
} else {
check(
modulePage.status === 200 && modulePageBody.includes('id="ekklesia-host"'),
`GET /${moduleId} through the proxy is the Dashboard page while the module is installed`,
);
}
if (moduleId === "example") {
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
if (expectation === "absent") {
check( check(
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}', records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
"the absent module's routes are not mounted", "the absent module's routes are not mounted",
); );
} else { } else {
const expected = JSON.parse(expectation); const expected = JSON.parse(expectation);
check(installed, "the module inventory lists the `example` module");
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null; const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
check( check(
JSON.stringify(values) === JSON.stringify(expected), JSON.stringify(values) === JSON.stringify(expected),
`the module's records route returns ${JSON.stringify(expected)}`, `the module's records route returns ${JSON.stringify(expected)}`,
); );
} }
} else if (expectation !== "absent" && probePath) {
const probe = await fetch(`${apiOrigin}${probePath}`, { headers });
await probe.arrayBuffer();
check(probe.status === 200, `the module probe ${probePath} responds`);
}
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, { const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
body: "{}", body: "{}",
+56 -5
View File
@@ -14,7 +14,7 @@
// stream-failure the first realtime connection is refused: is realtime re-established? // stream-failure the first realtime connection is refused: is realtime re-established?
// caching-probe can a display on the API origin store storage-origin images in Cache Storage? // caching-probe can a display on the API origin store storage-origin images in Cache Storage?
// stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline // stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline
// revoke revoke the paired kiosk while its display is open // revoke revoke the paired kiosk while its display is open, then pair it again
// //
// Every DevTools command and every page-side request has its own deadline, and a step has an overall // Every DevTools command and every page-side request has its own deadline, and a step has an overall
// budget. A call that never answers fails the step with a line naming that exact call, because a call // budget. A call that never answers fails the step with a line naming that exact call, because a call
@@ -426,7 +426,9 @@ async function seedSignage(owner) {
const base = "/api/v1/modules/signage"; const base = "/api/v1/modules/signage";
const images = []; const images = [];
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) { for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
const uploaded = await owner.evaluate( let uploaded;
for (let attempt = 1; attempt <= 2; attempt += 1) {
uploaded = await owner.evaluate(
`(async () => { `(async () => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = 640; canvas.width = 640;
@@ -435,14 +437,37 @@ async function seedSignage(owner) {
context.fillStyle = ${JSON.stringify(colour)}; context.fillStyle = ${JSON.stringify(colour)};
context.fillRect(0, 0, 640, 360); context.fillRect(0, 0, 640, 360);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
const response = await fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, { return fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST", body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
signal: AbortSignal.timeout(${requestTimeoutMs}), signal: AbortSignal.timeout(${requestTimeoutMs}),
}); }).then(
return { status: response.status, body: await response.json().catch(() => null) }; async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
);
})()`, })()`,
`uploading slide ${index}`, `uploading slide ${index}`,
); );
if (
attempt === 1 &&
uploaded.status === 503 &&
uploaded.body?.code === "SIGNAGE_STORAGE_UNAVAILABLE"
) {
note(`slide ${index} met one transient storage outage; retrying once`);
await sleep(1_000);
continue;
}
break;
}
if (uploaded.status === "timeout") {
throw new HangError(`HTTP POST ${base}/images from page "owner"`, requestTimeoutMs);
}
if (uploaded.status !== 200 && uploaded.status !== 201) {
const code =
typeof uploaded.body?.code === "string" && /^SIGNAGE_[A-Z_]+$/u.test(uploaded.body.code)
? ` (${uploaded.body.code})`
: "";
throw new Error(`Uploading slide ${index} returned HTTP ${uploaded.status}${code}.`);
}
images.push(uploaded.body?.image?.id); images.push(uploaded.body?.image?.id);
} }
check( check(
@@ -951,6 +976,32 @@ async function run() {
result.stopped, result.stopped,
`a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`, `a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`,
); );
// Revocation is recoverable: the same screen pairs again and resumes, keeping its place in the
// module's roster. It also leaves the kiosk paired for the steps that run after this one.
const rosterBefore = moduleId === "signage" ? (await signageViewers(owner.page)).length : 0;
if (await page.waitFor(find("display-pairing-code"), "a fresh pairing code after revocation")) {
const code = (
await page.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code")
).replace(/\D/gu, "");
const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", {
code,
deviceName: "Repro kiosk",
});
check(approved.status === 200, "the owner approves the revoked screen's new code");
check(
await waitLive(page, "the re-paired display to go live"),
"a revoked screen is paired again and resumes without a reload",
);
if (moduleId === "signage") {
const rosterAfter = (await signageViewers(owner.page)).length;
check(
rosterAfter === rosterBefore,
`re-pairing keeps the screen's existing roster entry (${rosterBefore} before, ${rosterAfter} after)`,
);
}
} else {
check(false, "a revoked screen shows a fresh pairing code");
}
await page.close(); await page.close();
kiosk.close(); kiosk.close();
await finishOwner(owner); await finishOwner(owner);
+169 -21
View File
@@ -7,6 +7,8 @@
# bash scripts/smoke-deploy.sh builds the image first # bash scripts/smoke-deploy.sh builds the image first
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh # EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh # EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
# EKKLESIA_SMOKE_MODULE_RELEASE_DIR=<dir> EKKLESIA_SMOKE_MODULE_ID=<id> \
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
# #
# Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile. # Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile.
# It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the # It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the
@@ -15,10 +17,10 @@
# ADR 0048 end to end with that same image: # ADR 0048 end to end with that same image:
# #
# - it boots with no modules; # - it boots with no modules;
# - the repository's module template is packaged by the canonical packager as an Ekklesia Module # - either the repository's module template is packaged by the canonical packager, or an external
# Package v1 (ADR 0049; scripts/lib/build-module-package.Dockerfile only provides the build # canonical release pair is supplied through EKKLESIA_SMOKE_MODULE_RELEASE_DIR; the package is
# environment), verified by the same image's own verifier, extracted to staging, installed into # verified by the same image's own verifier, extracted to staging, installed into module storage,
# module storage, its migrations planned and explicitly applied, and the API restarted; # its migrations planned and explicitly applied, and the API restarted;
# - a real headless Chromium drives the Dashboard, the module, its display, and sign-out; # - a real headless Chromium drives the Dashboard, the module, its display, and sign-out;
# - the module is removed and the API restarted: Core still runs, migration planning reports the # - the module is removed and the API restarted: Core still runs, migration planning reports the
# module's retained history, and its data survives; # module's retained history, and its data survives;
@@ -83,6 +85,28 @@ same-origin | split) ;;
;; ;;
esac esac
module_release_directory="${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"
module_id="${EKKLESIA_SMOKE_MODULE_ID:-example}"
module_probe_path="${EKKLESIA_SMOKE_MODULE_PROBE_PATH:-}"
[[ "$module_id" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]] || {
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_ID is not a valid module id." >&2
exit 1
}
if [[ -n "$module_release_directory" ]]; then
[[ "$topology" == split ]] || {
echo "smoke-deploy: an external module release requires the split topology." >&2
exit 1
}
[[ -d "$module_release_directory" ]] || {
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_RELEASE_DIR is not a directory." >&2
exit 1
}
fi
if [[ -n "$module_probe_path" && "$module_probe_path" != "/api/v1/modules/${module_id}/"* ]]; then
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_PROBE_PATH must be inside the selected module's API namespace." >&2
exit 1
fi
run_id="$(openssl rand -hex 4)" run_id="$(openssl rand -hex 4)"
# Unique per invocation, and findable by run and job if this script is killed before its trap runs # Unique per invocation, and findable by run and job if this script is killed before its trap runs
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries # (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries
@@ -217,8 +241,10 @@ if [[ -z "$image" ]]; then
fi fi
if [[ "$topology" == split ]]; then if [[ "$topology" == split ]]; then
# The module template as a Module Package v1, from the canonical packager. Never a second platform # Never a second platform image: the package is installed into module storage below, after the
# image: the package is installed into module storage below, after the image verifies it. # generic image verifies it. The default keeps Core's own template smoke self-contained; downstream
# module CI hands this harness the exact release pair it built instead.
if [[ -z "$module_release_directory" ]]; then
step "Packaging the module template with the canonical Ekklesia packager" step "Packaging the module template with the canonical Ekklesia packager"
source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}" source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package." [[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
@@ -226,13 +252,20 @@ if [[ "$topology" == split ]]; then
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \ --label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
--output "type=local,dest=${work}/module-package" . >/dev/null || --output "type=local,dest=${work}/module-package" . >/dev/null ||
fail "the module package did not build." fail "the module package did not build."
package_output="$work/module-package"
else
step "Accepting the external module's canonical release pair"
package_output="$module_release_directory"
fi
# The archive and its digest are one release: copied together, from this run's own build output, # The archive and its digest are one release: copied together, from this run's own build output,
# into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is # into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is
# mode 0700 and owned by whoever built it, so it is never handed to the verifier itself. # mode 0700 and owned by whoever built it, so it is never handed to the verifier itself.
package_archive="$(module_package_release "$work/module-package" "$work/package")" || package_archive="$(module_package_release "$package_output" "$work/package")" ||
fail "the packager did not produce exactly one archive and its digest." fail "the packager did not produce exactly one archive and its digest."
if [[ -z "$module_release_directory" ]]; then
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] || [[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
fail "the packager produced an unexpected archive name." fail "the packager produced an unexpected archive name."
fi
echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))" echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))"
fi fi
@@ -446,22 +479,48 @@ module_storage() {
install_test_module() { install_test_module() {
local verified local verified
verified="$( verified="$(
docker run --rm --network none --read-only --tmpfs /tmp --cap-drop ALL \ docker run --rm --network none --read-only \
--security-opt no-new-privileges \ --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 "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \ -v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \ --entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
--sha256-file "/package/${package_archive}.sha256" --extract-to /staging/example 2>&1 --sha256-file "/package/${package_archive}.sha256" --extract-to "/staging/${module_id}" 2>&1
)" || fail "the module package did not verify: ${verified}" )" || fail "the module package did not verify: ${verified}"
echo "ok ${verified%%$'\n'*}" echo "ok ${verified%%$'\n'*}"
docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \ docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
-v "$daemon_work/staging:/staging" "$postgres_image" -ec ' -v "$daemon_work/staging:/staging" -e MODULE_ID="$module_id" "$postgres_image" -ec '
rm -rf /modules/.incoming-example && cp -R /staging/example /modules/.incoming-example && incoming="/modules/.incoming-${MODULE_ID}"
rm -rf /staging/example && chown -R 0:0 /modules/.incoming-example && installed="/modules/${MODULE_ID}"
chmod -R a+rX,go-w /modules/.incoming-example && staged="/staging/${MODULE_ID}"
mv /modules/.incoming-example /modules/example' || rm -rf "$incoming" && cp -R "$staged" "$incoming" && rm -rf "$staged" &&
chown -R 0:0 "$incoming" && chmod -R a+rX,go-w "$incoming" && mv "$incoming" "$installed"' ||
fail "the verified module could not be installed into module storage." fail "the verified module could not be installed into module storage."
} }
# The package and its claimed digest are deliberately made to disagree. The verifier must reject it
# before extraction; the valid release pair is left untouched for the installation that follows.
reject_invalid_package() {
local rejected zero_digest
zero_digest="$(printf '0%.0s' {1..64})"
if rejected="$(
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 "$daemon_work/package:/package:ro" \
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
--sha256 "$zero_digest" 2>&1
)"; then
fail "the verifier accepted a package whose archive did not match its claimed digest."
fi
grep -q '^\[MODULE_PACKAGE_DIGEST_MISMATCH\]' <<<"$rejected" ||
fail "the invalid package was rejected for an unexpected reason."
echo "ok an invalid package is rejected before installation"
}
# restart_api <command...>: stops the API, runs the command, and starts the API again. A controlled # restart_api <command...>: stops the API, runs the command, and starts the API again. A controlled
# restart is the only way a module change is picked up; nothing is ever hot loaded. # restart is the only way a module change is picked up; nothing is ever hot loaded.
restart_api() { restart_api() {
@@ -473,9 +532,25 @@ restart_api() {
fi fi
} }
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return. # recreate_api removes the application container, not its installation-owned module bind mount or
# database volumes, then creates a fresh container from the same generic image.
recreate_api() {
"${compose[@]}" stop api >/dev/null 2>&1 || fail "the API did not stop for recreation."
"${compose[@]}" rm --force api >/dev/null 2>&1 || fail "the API container was not removed."
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 api 2>&1)"; then
compose_report_not_ready "$up_output" "${compose[@]}"
fail "the recreated API did not become healthy."
fi
}
# module_check <expectation> [page]: "absent", or the exact JSON array of values `example` must
# return. `page` is whether its Dashboard address is served (ADR 0057): by default, exactly when the
# module is running; "served" for a module that is installed but disabled, whose address still opens
# the Dashboard so it can say the module is unavailable.
module_check() { module_check() {
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \ client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
-e SMOKE_MODULE_PAGE="${2:-}" \
-e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
} }
@@ -488,21 +563,36 @@ if [[ "$topology" == split ]]; then
step "Booting the generic image with empty module storage" step "Booting the generic image with empty module storage"
module_check absent || fail "the image without modules did not run module-free." module_check absent || fail "the image without modules did not run module-free."
step "Rejecting an invalid package before installation"
reject_invalid_package
step "Installing the test module into module storage, migrating explicitly, and restarting the API" step "Installing the test module into module storage, migrating explicitly, and restarting the API"
install_module_and_migrate() { install_module_and_migrate() {
install_test_module install_test_module
local plan local plan
plan="$(plan_output)" plan="$(plan_output)"
grep -q '^- \[pending\] example:0000_initial ' <<<"$plan" || grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan" ||
fail "the plan does not list the installed module's migration as pending." fail "the plan does not list the installed module's migration as pending."
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null || "${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "applying the installed module's migration failed." fail "applying the installed module's migration failed."
plan="$(plan_output)"
grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
fail "the plan does not list the installed module's migration as applied."
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
fail "the installed module still has a pending migration after apply."
fi
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "reapplying migrations was not idempotent."
} }
restart_api install_module_and_migrate restart_api install_module_and_migrate
if [[ "$module_id" == example ]]; then
module_check '[]' || fail "the installed module did not run from module storage." module_check '[]' || fail "the installed module did not run from module storage."
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \ psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
>/dev/null || fail "could not write the module's own data." >/dev/null || fail "could not write the module's own data."
module_check '["Hello, kept"]' || fail "the installed module did not read its own data." module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
else
module_check present || fail "the installed module did not run from module storage."
fi
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out" step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA, # Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
@@ -513,6 +603,8 @@ if [[ "$topology" == split ]]; then
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout | spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)" openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
browser_container="${project}-browser" browser_container="${project}-browser"
start_owner_browser() {
docker rm --force "$browser_container" >/dev/null 2>&1 || true
# The image's entry script passes its arguments on unquoted, so none may contain a space: the # The image's entry script passes its arguments on unquoted, so none may contain a space: the
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule. # hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \ docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
@@ -521,8 +613,11 @@ if [[ "$topology" == split ]]; then
"$browser_image" \ "$browser_image" \
"--ignore-certificate-errors-spki-list=${spki}" \ "--ignore-certificate-errors-spki-list=${spki}" \
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start." --user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
}
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs" cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
stage browser.mjs stage browser.mjs
run_browser_checks() {
start_owner_browser
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
--label "${EKKLESIA_CI_LABEL}=${project}" \ --label "${EKKLESIA_CI_LABEL}=${project}" \
--network "$edge_network" \ --network "$edge_network" \
@@ -530,7 +625,9 @@ if [[ "$topology" == split ]]; then
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \ -e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \ -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \ -v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed." --entrypoint node "$image" /smoke/browser.mjs
}
run_browser_checks || fail "browser checks failed."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
# A kiosk: a second browser whose profile directory is a volume, so it outlives the container # A kiosk: a second browser whose profile directory is a volume, so it outlives the container
@@ -566,6 +663,7 @@ if [[ "$topology" == split ]]; then
--network "$edge_network" \ --network "$edge_network" \
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \ -e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
-e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \ -e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \
-e SMOKE_CALL_TIMEOUT_MS="${EKKLESIA_SMOKE_CALL_TIMEOUT_MS:-30000}" \
-e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \ -e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \ -e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
-e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \ -e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \
@@ -607,29 +705,79 @@ if [[ "$topology" == split ]]; then
((display_status == 0)) || fail "display-device checks failed." ((display_status == 0)) || fail "display-device checks failed."
fi fi
step "Restarting the API process with the installed module and data intact"
restart_api
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the module or its data did not survive an API restart."
else
module_check present || fail "the module did not survive an API restart."
fi
run_browser_checks || fail "the module UI did not survive an API restart."
step "Recreating the API container with the same module storage and database volumes"
recreate_api
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the module or its data did not survive container recreation."
else
module_check present || fail "the module did not survive container recreation."
fi
plan="$(plan_output)"
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
fail "container recreation made an applied module migration pending again."
fi
run_browser_checks || fail "the module UI did not survive container recreation."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
display_driver 120 restart || fail "the display runtime or its data did not survive container recreation."
fi
step "Disabling and re-enabling the module through the supported lifecycle command"
restart_api "${compose[@]}" run --rm --no-deps migrate node \
core/dist/cli/set-module-enabled.js --disable --module "$module_id"
module_check absent served || fail "the disabled module was still reported as running."
module_storage "test -d /modules/${module_id}" ||
fail "disabling the module removed its installed package."
restart_api "${compose[@]}" run --rm --no-deps migrate node \
core/dist/cli/set-module-enabled.js --enable --module "$module_id"
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the re-enabled module did not return with its data."
else
module_check present || fail "the re-enabled module did not return."
fi
run_browser_checks || fail "the re-enabled module UI did not return."
step "Removing the module from module storage and restarting: Core runs, its data stays" step "Removing the module from module storage and restarting: Core runs, its data stays"
restart_api module_storage 'rm -rf /modules/example' restart_api module_storage "rm -rf /modules/${module_id}"
module_check absent || fail "the removed module is still running." module_check absent || fail "the removed module is still running."
plan="$(plan_output)" plan="$(plan_output)"
grep -q '^- \[retained\] example: 1 applied migration kept; the module is not installed$' \ grep -q "^- \\[retained\\] ${module_id}: .*applied migration.*kept; the module is not installed$" \
<<<"$plan" || fail "migration planning did not report the removed module's retained history." <<<"$plan" || fail "migration planning did not report the removed module's retained history."
grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" || grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" ||
fail "migration planning did not succeed cleanly with the module absent." fail "migration planning did not succeed cleanly with the module absent."
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null || "${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "applying migrations failed with the module absent." fail "applying migrations failed with the module absent."
if [[ "$module_id" == example ]]; then
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] || [[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
fail "the removed module's data did not survive." fail "the removed module's data did not survive."
fi
echo "ok the module is gone, Core runs, its history is retained, and its data survives" echo "ok the module is gone, Core runs, its history is retained, and its data survives"
step "Reinstalling the exact same package and restarting: the module returns with its previous data" step "Reinstalling the exact same package and restarting: the module returns with its previous data"
restart_api install_test_module restart_api install_test_module
plan="$(plan_output)" plan="$(plan_output)"
grep -q '^- \[applied\] example:0000_initial ' <<<"$plan" || grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
fail "the restored module's migration history is not valid." fail "the restored module's migration history is not valid."
if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then
fail "the restored module left retained or pending migrations." fail "the restored module left retained or pending migrations."
fi fi
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the restored module did not return with its data." module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
else
module_check present || fail "the restored module did not return."
fi
run_browser_checks || fail "the restored module UI did not return."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
display_driver 120 restart || fail "the restored display runtime did not return with its data."
fi
echo "ok the restored module runs again, with its history strict and its data intact" echo "ok the restored module runs again, with its history strict and its data intact"
fi fi
+18 -5
View File
@@ -4,8 +4,10 @@
# #
# bash scripts/test-ci-sweep.sh # bash scripts/test-ci-sweep.sh
# #
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, so # Every resource it creates is named and labeled for this run alone, under fake run identifiers, and
# it can never touch a real stack; and it removes all of them on exit, whatever happens. # every sweep it runs is confined to those identifiers, so it can never touch a real stack: the
# runner's daemon is shared, and another job's live stack may be on it right now. It removes all of
# its own resources on exit, whatever happens.
set -Eeuo pipefail set -Eeuo pipefail
@@ -91,10 +93,14 @@ source "$repository_root/scripts/lib/ci-project.sh"
# Fake run identifiers: nothing here can match a real run's stacks. # Fake run identifiers: nothing here can match a real run's stacks.
export GITHUB_RUN_ID="sweeptest${nonce}" export GITHUB_RUN_ID="sweeptest${nonce}"
export GITHUB_RUN_ATTEMPT="1" export GITHUB_RUN_ATTEMPT="1"
# Every stack this test sweeps begins with this; nothing it sweeps reaches past it.
own_run="$(ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID}-")"
mine="$(EKKLESIA_CI_JOB_KEY=alpha ci_project_name mine)" mine="$(EKKLESIA_CI_JOB_KEY=alpha ci_project_name mine)"
sibling="$(EKKLESIA_CI_JOB_KEY=beta ci_project_name sibling)" sibling="$(EKKLESIA_CI_JOB_KEY=beta ci_project_name sibling)"
foreign="ekklesia-sweep-control-${nonce}" foreign="ekklesia-sweep-control-${nonce}"
for project in "$mine" "$sibling" "$foreign"; do # Stands in for another run's live stack on the shared daemon — a module's integration job, say.
bystander="$(GITHUB_RUN_ID="bystander${nonce}" EKKLESIA_CI_JOB_KEY=gamma ci_project_name live)"
for project in "$mine" "$sibling" "$foreign" "$bystander"; do
make_stack "$project" make_stack "$project"
done done
# A job container connected to this job's stack network, exactly as compose-job-network.sh does. # A job container connected to this job's stack network, exactly as compose-job-network.sh does.
@@ -115,12 +121,19 @@ check '! env -u GITHUB_RUN_ID bash scripts/ci-sweep.sh --job >/dev/null 2>&1' \
"--job without GITHUB_RUN_ID fails" "--job without GITHUB_RUN_ID fails"
echo "--- --stale removes only CI stacks older than its threshold" echo "--- --stale removes only CI stacks older than its threshold"
bash scripts/ci-sweep.sh --stale bash scripts/ci-sweep.sh --stale --prefix "$own_run"
check 'project_exists "$sibling"' "a young CI stack survives the default threshold" check 'project_exists "$sibling"' "a young CI stack survives the default threshold"
bash scripts/ci-sweep.sh --stale --max-age-seconds 0 bash scripts/ci-sweep.sh --stale --max-age-seconds 0 --prefix "$own_run"
check '! project_exists "$sibling"' "a CI stack older than the threshold is removed" check '! project_exists "$sibling"' "a CI stack older than the threshold is removed"
check '! staging_exists "$sibling"' "its staging directory too" check '! staging_exists "$sibling"' "its staging directory too"
check 'project_exists "$foreign"' "a stack outside the CI prefix is never removed, at any age" check 'project_exists "$foreign"' "a stack outside the CI prefix is never removed, at any age"
check 'project_exists "$bystander"' \
"another run's stack is never removed by a narrowed sweep, even past the threshold"
echo "--- --prefix only ever narrows the sweep"
check '! bash scripts/ci-sweep.sh --stale --prefix "ekklesia-" >/dev/null 2>&1' \
"a prefix outside the CI prefix is refused"
check '! bash scripts/ci-sweep.sh --job --prefix "$own_run" >/dev/null 2>&1' \
"--prefix with --job is refused"
if ((failures > 0)); then if ((failures > 0)); then
echo "test-ci-sweep: ${failures} check(s) failed." >&2 echo "test-ci-sweep: ${failures} check(s) failed." >&2
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# ADR 0055's full proof, with real sources: the native proof module (sharp 0.35.5, libvips 1.3.4) and
# its corresponding-source artifact.
#
# bash scripts/test-corresponding-source.sh
#
# 1. Acquisition (network, first run only): every checked-in recipe's recorded source files are
# downloaded into a verified cache, each checked against its recorded size and SHA-256. A file
# already in the cache with the right bytes is never fetched again.
# 2. Construction and the gate (offline): the artifact is built twice from unrelated directories and
# must be byte-identical; the public-distribution gate must approve it, and must refuse it once it
# is removed or corrupted; sharp must still run from the verified binary package.
#
# EKKLESIA_CORRESPONDING_SOURCE_CACHE chooses the cache directory; the default is
# ${XDG_CACHE_HOME:-$HOME/.cache}/ekklesia/corresponding-sources. It is about 200 MB per recipe.
set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
cache="${EKKLESIA_CORRESPONDING_SOURCE_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/ekklesia/corresponding-sources}"
pnpm exec turbo run build --filter=@ekklesia/module-pack... --filter=@ekklesia/core... >/dev/null
recipes="packages/module-pack/corresponding-sources"
while IFS= read -r recipe; do
relative="${recipe#"$recipes"/}"
relative="${relative%/recipe.json}"
echo "==> Acquiring the recorded sources for ${relative%/*}@${relative##*/}"
node packages/module-pack/bin/ekklesia-corresponding-source.mjs acquire \
--recipe "${relative%/*}@${relative##*/}" --cache "$cache"
done < <(find "$recipes" -name recipe.json | LC_ALL=C sort)
echo "==> Building, gating, and running the native proof offline"
EKKLESIA_CORRESPONDING_SOURCE_CACHE="$cache" EKKLESIA_CORRESPONDING_SOURCE_REQUIRED=1 \
pnpm --filter @ekklesia/module-pack exec vitest run tests/corresponding-source-native.test.ts
+1 -1
View File
@@ -110,7 +110,7 @@ for user in publisher outsider; do
password_variable="${user}_password" password_variable="${user}_password"
api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null
done done
owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout) owners=(Zayin ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
for owner in "${owners[@]}"; do for owner in "${owners[@]}"; do
api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null
team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' | team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' |
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/shell", "name": "@ekklesia/shell",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -16,6 +16,10 @@
"types": "./dist/dashboard-assets.d.ts", "types": "./dist/dashboard-assets.d.ts",
"import": "./dist/dashboard-assets.js" "import": "./dist/dashboard-assets.js"
}, },
"./dashboard-routes": {
"types": "./dist/dashboard-routes.d.ts",
"import": "./dist/dashboard-routes.js"
},
"./display-assets": { "./display-assets": {
"types": "./dist/display-assets.d.ts", "types": "./dist/display-assets.d.ts",
"import": "./dist/display-assets.js" "import": "./dist/display-assets.js"
+7 -1
View File
@@ -89,7 +89,13 @@ await copyFile(
if (serve) { if (serve) {
const context = await esbuild.context(buildOptions); const context = await esbuild.context(buildOptions);
await context.watch(); await context.watch();
const { host, port } = await context.serve({ servedir: outputDirectory }); // Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as
// `/example` reaches the shell's router here as it does from `apps/api` (ADR 0057).
// Development only: the API serves the page only at its platform pages and installed modules'.
const { host, port } = await context.serve({
fallback: path.join(outputDirectory, "index.html"),
servedir: outputDirectory,
});
console.log(`Ekklesia shell (development) on http://${host}:${port}`); console.log(`Ekklesia shell (development) on http://${host}:${port}`);
console.log( console.log(
apiBaseUrl === "" apiBaseUrl === ""
@@ -0,0 +1,82 @@
import { designToken } from "@ekklesia/design-tokens";
import type { CSSProperties, MouseEvent, ReactElement, ReactNode } from "react";
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
import type { HostHistory } from "./host-history.js";
export interface DashboardLinkProps {
readonly history: HostHistory;
readonly to: DashboardDestination;
readonly children: ReactNode;
/** Marks the link as the page currently open, for assistive technology and visually. */
readonly current?: boolean;
readonly variant?: "navigation" | "action";
readonly "data-testid"?: string;
}
const variantClass = {
action: "ek-button ek-focusable ek-button--secondary",
navigation: "ek-button ek-focusable ek-button--quiet",
} as const;
const navigationStyle: CSSProperties = {
justifyContent: "flex-start",
textDecoration: "none",
width: "100%",
};
/** The open page is shown by weight as well as colour, so it never depends on colour alone. */
const currentStyle: CSSProperties = {
...navigationStyle,
background: designToken("state.selected-background"),
color: designToken("state.selected-foreground"),
fontWeight: designToken("typography.weight-bold"),
};
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
return (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
);
}
/**
* A link to a Dashboard page, as a real `<a href>` (ADR 0057).
*
* A real link rather than a button, because it is one: it can be opened in a new tab, copied, and
* bookmarked, and assistive technology announces it as a link. An ordinary click opens the page in
* place through the shell's history instead of reloading the document; every other click — with a
* modifier key, or the middle button — is left to the browser.
*/
export function DashboardLink(props: DashboardLinkProps): ReactElement {
const href = dashboardPath(props.to);
const variant = props.variant ?? "navigation";
return (
<a
aria-current={props.current ? "page" : undefined}
className={variantClass[variant]}
data-testid={props["data-testid"]}
href={href}
onClick={(event) => {
if (isBrowserHandledClick(event)) {
return;
}
event.preventDefault();
props.history.push(href);
}}
style={
variant === "action"
? { textDecoration: "none" }
: props.current
? currentStyle
: navigationStyle
}
>
{props.children}
</a>
);
}
+41 -3
View File
@@ -1,5 +1,5 @@
import { designToken } from "@ekklesia/design-tokens"; import { designToken } from "@ekklesia/design-tokens";
import { Button, Field, TextInput } from "@ekklesia/ui"; import { Button, Dialog, Field, TextInput } from "@ekklesia/ui";
import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react"; import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react";
import { import {
approveDevicePairing, approveDevicePairing,
@@ -61,6 +61,10 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [pairing, setPairing] = useState<PairingStep>({ step: "idle" }); const [pairing, setPairing] = useState<PairingStep>({ step: "idle" });
/** The device an administrator asked to revoke and has not yet confirmed. */
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
const [revoking, setRevoking] = useState(false);
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
const refresh = useCallback(async (): Promise<void> => { const refresh = useCallback(async (): Promise<void> => {
try { try {
@@ -106,13 +110,15 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
}; };
const revoke = async (deviceId: string): Promise<void> => { const revoke = async (deviceId: string): Promise<void> => {
setRevoking(true);
try { try {
await revokeHostDevice(config, deviceId); await revokeHostDevice(config, deviceId);
await refresh();
} catch { } catch {
// Left to the next refresh rather than asserting a failure the operator cannot act on. // Left to the next refresh rather than asserting a failure the operator cannot act on.
} }
await refresh(); await refresh();
setRevoking(false);
setConfirming(undefined);
}; };
return ( return (
@@ -195,7 +201,7 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
{device.revoked ? undefined : ( {device.revoked ? undefined : (
<Button <Button
data-testid={`device-revoke-${device.id}`} data-testid={`device-revoke-${device.id}`}
onClick={() => void revoke(device.id)} onClick={() => setConfirming(device)}
size="sm" size="sm"
> >
Revoke Revoke
@@ -203,6 +209,38 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
)} )}
</div> </div>
))} ))}
{/* Revoking strands the screen until someone re-pairs it in person, so the consequence is
spelled out before it happens — never a browser-native confirm. */}
<Dialog
actions={
<>
<Button
data-testid="device-revoke-cancel"
disabled={revoking}
onClick={dismissConfirmation}
>
Cancel
</Button>
<Button
data-testid="device-revoke-confirm"
disabled={revoking}
onClick={() => (confirming ? void revoke(confirming.id) : undefined)}
variant="danger"
>
Revoke display
</Button>
</>
}
onDismiss={dismissConfirmation}
open={confirming !== undefined}
title={`Revoke ${confirming?.name ?? "this display"}?`}
>
<p style={{ margin: 0 }}>
The screen stops showing content within a few seconds and returns to its pairing code.
This cannot be undone: to use it again, pair it from the screen with a new code.
</p>
</Dialog>
</section> </section>
); );
} }
+125 -44
View File
@@ -1,7 +1,15 @@
import { designToken } from "@ekklesia/design-tokens"; import { designToken } from "@ekklesia/design-tokens";
import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { Button } from "@ekklesia/ui"; import { Button } from "@ekklesia/ui";
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react"; import {
type ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { import {
HostAuthError, HostAuthError,
type HostAuthLanding, type HostAuthLanding,
@@ -12,6 +20,7 @@ import {
signInWithEmail, signInWithEmail,
signOutHost, signOutHost,
} from "../auth-protocol.js"; } from "../auth-protocol.js";
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
import { import {
bootstrapHost, bootstrapHost,
type DashboardNavigationEntry, type DashboardNavigationEntry,
@@ -20,8 +29,11 @@ import {
HostProtocolError, HostProtocolError,
loadHostPrincipal, loadHostPrincipal,
} from "../host-protocol.js"; } from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.js";
import { DevicePanel } from "./DevicePanel.js"; import { DevicePanel } from "./DevicePanel.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.js"; import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import type { HostHistory } from "./host-history.js";
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
import { LoginForm } from "./LoginForm.js"; import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js"; import { ResetPasswordForm } from "./ResetPasswordForm.js";
@@ -101,6 +113,15 @@ export interface HostAppProps {
* so there is nothing an open redirect could use. * so there is nothing an open redirect could use.
*/ */
readonly authLanding?: HostAuthLanding; readonly authLanding?: HostAuthLanding;
/**
* The address bar (ADR 0057). The current path decides which page is open, so a refresh, a
* bookmark, and Back and Forward all land where the person was. The entry point passes the
* browser's; tests pass an in-memory one. Must be a stable reference.
*/
readonly history: HostHistory;
/** Names the browser tab after the open page. Supplied by the entry point, which owns `document`;
* left unset, the shell names nothing. */
readonly setDocumentTitle?: (title: string) => void;
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */ /** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter; readonly importModule?: ModuleUiImporter;
} }
@@ -167,6 +188,8 @@ const shellStyles = {
main: { main: {
boxSizing: "border-box", boxSizing: "border-box",
minWidth: 0, minWidth: 0,
// A programmatic focus target after a page change (never in the tab order), not a control.
outline: "none",
overflowX: "auto", overflowX: "auto",
padding: designToken("spacing.xl"), padding: designToken("spacing.xl"),
}, },
@@ -194,25 +217,30 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
]; ];
} }
function ModuleButton(props: { function ModuleLink(props: {
readonly entry: DashboardNavigationEntry; readonly entry: DashboardNavigationEntry;
readonly selected: boolean; readonly history: HostHistory;
readonly onSelect: () => void; readonly route: DashboardRoute;
}): ReactElement { }): ReactElement {
const moduleId = props.entry.descriptor.id;
return ( return (
<Button <DashboardLink
aria-current={props.selected ? "page" : undefined} current={props.route.kind === "module" && props.route.moduleId === moduleId}
aria-pressed={props.selected} data-testid={`select-${moduleId}`}
data-testid={`select-${props.entry.descriptor.id}`} history={props.history}
onClick={props.onSelect} to={{ kind: "module", moduleId }}
style={{ justifyContent: "flex-start", width: "100%" }}
variant="quiet"
> >
{props.entry.name} {props.entry.name}
</Button> </DashboardLink>
); );
} }
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
* not a page change. */
function routeKey(route: DashboardRoute): string {
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
}
function HostMessage(props: { function HostMessage(props: {
readonly children: string; readonly children: string;
readonly testId: string; readonly testId: string;
@@ -265,21 +293,27 @@ async function resolveBootstrapState(
/** /**
* The Ekklesia Dashboard shell and its host-owned authentication boundary. * The Ekklesia Dashboard shell and its host-owned authentication boundary.
* *
* The login form replaces the protected application in place. There is no redirect parameter, URL * The login form replaces the protected application in place. There is no redirect parameter,
* parser, location assignment, or client router in this shell, so the current internal Dashboard URL * return value, or location assignment in this shell, so the current internal Dashboard URL simply
* simply remains the current URL through sign-in and no external/protocol-relative return value can * remains the current URL through sign-in — a deep link opens on its own page once the person has
* become a navigation sink. Modules receive the same five-field context after authentication; they * signed in — and no external/protocol-relative value can become a navigation sink. The shell only
* neither render platform login nor receive sign-in/sign-out controls. * ever moves between its own paths (ADR 0057). Modules receive the same context after
* authentication; they neither render platform login nor receive sign-in/sign-out controls.
*/ */
export function HostApp(props: HostAppProps): ReactElement { export function HostApp(props: HostAppProps): ReactElement {
const [state, setState] = useState<BootstrapState>({ status: "loading" }); const [state, setState] = useState<BootstrapState>({ status: "loading" });
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
const [bootstrapAttempt, setBootstrapAttempt] = useState(0); const [bootstrapAttempt, setBootstrapAttempt] = useState(0);
const activeTransitionRef = useRef<AbortController | undefined>(undefined); const activeTransitionRef = useRef<AbortController | undefined>(undefined);
const revalidationRef = useRef<AbortController | undefined>(undefined); const revalidationRef = useRef<AbortController | undefined>(undefined);
const generationRef = useRef(0); const generationRef = useRef(0);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const { config, importModule } = props; const mainRef = useRef<HTMLElement>(null);
const shownRouteRef = useRef<string | undefined>(undefined);
const { config, history, importModule, setDocumentTitle } = props;
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
const readPathname = useCallback(() => history.pathname(), [history]);
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
const route = useMemo(() => parseDashboardPath(pathname), [pathname]);
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign // Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter // in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
// cannot resurrect a screen the person has left. // cannot resurrect a screen the person has left.
@@ -306,14 +340,16 @@ export function HostApp(props: HostAppProps): ReactElement {
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => { const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
setState(resolved); setState(resolved);
if (resolved.status === "ready") {
const first = allEntries(resolved.bootstrap)[0];
setSelectedKey(first ? moduleKey(first) : undefined);
} else {
setSelectedKey(undefined);
}
}, []); }, []);
// A non-canonical spelling of a real page (`/example/`) is corrected in place, adding no
// history entry, so what is bookmarked or shared next is the one canonical address.
useEffect(() => {
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
history.replace(dashboardPath(route));
}
}, [history, pathname, route]);
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
return () => { return () => {
@@ -329,7 +365,6 @@ export function HostApp(props: HostAppProps): ReactElement {
void bootstrapAttempt; void bootstrapAttempt;
const { controller, generation } = beginTransition(); const { controller, generation } = beginTransition();
setState({ status: "loading" }); setState({ status: "loading" });
setSelectedKey(undefined);
void resolveBootstrapState(config, controller.signal).then((resolved) => { void resolveBootstrapState(config, controller.signal).then((resolved) => {
if (!isCurrent(controller, generation)) { if (!isCurrent(controller, generation)) {
return; return;
@@ -381,7 +416,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const { controller, generation } = beginTransition(); const { controller, generation } = beginTransition();
// The authenticated subtree disappears before the request starts. That synchronously schedules // The authenticated subtree disappears before the request starts. That synchronously schedules
// ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions. // ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions.
setSelectedKey(undefined);
setState({ status: "signing-out" }); setState({ status: "signing-out" });
try { try {
await signOutHost(config, controller.signal); await signOutHost(config, controller.signal);
@@ -435,7 +469,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const transition = beginTransition(); const transition = beginTransition();
transition.controller.abort(); transition.controller.abort();
activeTransitionRef.current = undefined; activeTransitionRef.current = undefined;
setSelectedKey(undefined);
setAuthView({ kind: "sign-in" }); setAuthView({ kind: "sign-in" });
setState({ status: "anonymous" }); setState({ status: "anonymous" });
} }
@@ -451,7 +484,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const transition = beginTransition(); const transition = beginTransition();
transition.controller.abort(); transition.controller.abort();
activeTransitionRef.current = undefined; activeTransitionRef.current = undefined;
setSelectedKey(undefined);
console.error("[ekklesia] session revalidation failed"); console.error("[ekklesia] session revalidation failed");
setState({ status: "failed" }); setState({ status: "failed" });
}) })
@@ -464,6 +496,41 @@ export function HostApp(props: HostAppProps): ReactElement {
[beginTransition, config], [beginTransition, config],
); );
const openEntry =
state.status === "ready" && route.kind === "module"
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
: undefined;
const pageTitle =
state.status !== "ready"
? undefined
: route.kind === "overview"
? "Overview"
: route.kind === "module"
? (openEntry?.name ?? "This page isn’t available")
: "Page not found";
const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle;
useEffect(() => {
if (documentTitle !== undefined) {
setDocumentTitle?.(documentTitle);
}
}, [documentTitle, setDocumentTitle]);
// Moving to another page moves keyboard and screen-reader focus to the new page's content, as a
// full page load would have. The first page shown after sign-in or a refresh is left alone, and a
// canonical respelling of the same page is not a move.
const currentRouteKey = routeKey(route);
useEffect(() => {
if (state.status !== "ready") {
shownRouteRef.current = undefined;
return;
}
if (shownRouteRef.current !== undefined && shownRouteRef.current !== currentRouteKey) {
mainRef.current?.focus();
}
shownRouteRef.current = currentRouteKey;
}, [currentRouteKey, state.status]);
const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1); const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1);
if (state.status === "loading") { if (state.status === "loading") {
@@ -553,7 +620,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const { bootstrap } = state; const { bootstrap } = state;
const entries = allEntries(bootstrap); const entries = allEntries(bootstrap);
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
const { groups, ungrouped } = bootstrap.navigation; const { groups, ungrouped } = bootstrap.navigation;
return ( return (
@@ -569,22 +635,30 @@ export function HostApp(props: HostAppProps): ReactElement {
</header> </header>
<aside style={shellStyles.aside}> <aside style={shellStyles.aside}>
<DevicePanel config={config} /> <DevicePanel config={config} />
<nav aria-label="Dashboard" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<DashboardLink
current={route.kind === "overview"}
data-testid="select-overview"
history={history}
to={{ kind: "overview" }}
>
Overview
</DashboardLink>
{entries.length === 0 && groups.length === 0 ? ( {entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}> <p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface. No installed module provides a user interface.
</p> </p>
) : ( ) : undefined}
<nav aria-label="Modules" style={{ display: "grid", gap: designToken("spacing.xl") }}>
{groups.map((group) => ( {groups.map((group) => (
<section data-testid={`group-${group.id}`} key={group.id}> <section data-testid={`group-${group.id}`} key={group.id}>
<h2 style={shellStyles.groupLabel}>{group.label}</h2> <h2 style={shellStyles.groupLabel}>{group.label}</h2>
<div style={{ display: "grid", gap: "0.125rem" }}> <div style={{ display: "grid", gap: "0.125rem" }}>
{group.modules.map((entry) => ( {group.modules.map((entry) => (
<ModuleButton <ModuleLink
entry={entry} entry={entry}
history={history}
key={moduleKey(entry)} key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))} route={route}
selected={moduleKey(entry) === selectedKey}
/> />
))} ))}
</div> </div>
@@ -595,30 +669,37 @@ export function HostApp(props: HostAppProps): ReactElement {
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined} {groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
<div style={{ display: "grid", gap: "0.125rem" }}> <div style={{ display: "grid", gap: "0.125rem" }}>
{ungrouped.map((entry) => ( {ungrouped.map((entry) => (
<ModuleButton <ModuleLink
entry={entry} entry={entry}
history={history}
key={moduleKey(entry)} key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))} route={route}
selected={moduleKey(entry) === selectedKey}
/> />
))} ))}
</div> </div>
</section> </section>
) : undefined} ) : undefined}
</nav> </nav>
)}
</aside> </aside>
<main style={shellStyles.main}> <main aria-label={pageTitle} ref={mainRef} style={shellStyles.main} tabIndex={-1}>
{selected ? ( {route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry ? (
<ModuleUiFrame <ModuleUiFrame
config={config} config={config}
descriptor={selected.descriptor} descriptor={openEntry.descriptor}
{...(importModule ? { importModule } : {})} {...(importModule ? { importModule } : {})}
key={moduleKey(selected)} key={moduleKey(openEntry)}
onAuthorizationResponse={onAuthorizationResponse} onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal} principal={bootstrap.principal}
/> />
) : undefined} ) : (
<ModuleUnavailablePage history={history} />
)
) : (
<NotFoundPage history={history} />
)}
</main> </main>
</div> </div>
); );
+66
View File
@@ -0,0 +1,66 @@
import { EmptyState, PageHeader } from "@ekklesia/ui";
import type { ReactElement } from "react";
import { DashboardLink } from "./DashboardLink.js";
import type { HostHistory } from "./host-history.js";
/**
* The Dashboard's landing page (DESIGN.md §5, ADR 0057).
*
* The Overview is where the Dashboard opens. Its cards will come from modules through a contract
* that does not exist yet, so for now it honestly says it is empty rather than inventing content.
*/
export function OverviewPage(): ReactElement {
return (
<div data-testid="host-overview">
<PageHeader title="Overview" />
<EmptyState
description="Summaries from your modules will appear here as they become available. Choose a module in the navigation to open it."
title="Nothing to show yet"
/>
</div>
);
}
/**
* A module page whose module is not available to this Dashboard: it was disabled or removed, it
* provides no user interface, or the address was mistyped. The server's navigation is the only
* authority on what exists, and it does not say which of these applies, so neither does this page.
* It never echoes the address back.
*/
export function ModuleUnavailablePage(props: { readonly history: HostHistory }): ReactElement {
return (
<div data-testid="host-module-unavailable">
<PageHeader
actions={<OverviewAction history={props.history} />}
description="The module it belongs to may have been turned off or removed. If you expected to see it, ask an administrator."
title="This page isn’t available"
/>
</div>
);
}
/** Any other address inside the Dashboard. */
export function NotFoundPage(props: { readonly history: HostHistory }): ReactElement {
return (
<div data-testid="host-not-found">
<PageHeader
actions={<OverviewAction history={props.history} />}
description="There is nothing at this address. Check it, or start again from the Overview."
title="Page not found"
/>
</div>
);
}
function OverviewAction(props: { readonly history: HostHistory }): ReactElement {
return (
<DashboardLink
data-testid="host-go-to-overview"
history={props.history}
to={{ kind: "overview" }}
variant="action"
>
Go to Overview
</DashboardLink>
);
}
+128
View File
@@ -0,0 +1,128 @@
/**
* The shell's one view of the address bar (ADR 0057).
*
* The Dashboard's URL is the source of truth for which page is open: a refresh, a bookmark, and the
* browser's Back and Forward buttons all have to land on the same page that was showing. Everything
* in the shell reads the current path and moves between pages through this small interface rather
* than through `window.history`, for two reasons. It keeps the entry point the only file in this
* package that touches a browser global — the browser implementation is handed the window's pieces,
* it does not reach for them. And it lets every routing behaviour be tested against an in-memory
* history whose entries a test can inspect.
*
* Deliberately not a routing library. The shell's pages are a handful of host-owned screens plus one
* mounted module, and `react-router-dom` is refused to modules precisely because routing belongs to
* the host (ADR 0026); adopting it here would prejudge how a module's own pages are addressed, which
* a later decision owns.
*/
export interface HostHistory {
/** The current path, without query or fragment. */
pathname(): string;
/** Opens a new page, adding a history entry. Opening the page already showing adds none. */
push(path: string): void;
/** Corrects the current entry in place, adding none — for a non-canonical spelling of the same
* page. */
replace(path: string): void;
/** Notifies after every change, whether made here or by the browser's Back and Forward. */
subscribe(listener: () => void): () => void;
}
/** The parts of a `Window` the browser history needs, passed in by the entry point. */
export interface HostHistoryWindow {
readonly location: Pick<Location, "pathname">;
readonly history: Pick<History, "pushState" | "replaceState">;
addEventListener(type: "popstate", listener: () => void): void;
removeEventListener(type: "popstate", listener: () => void): void;
}
/**
* The address bar itself. `pushState` and `replaceState` fire no event of their own, so this
* notifies its subscribers after each one; `popstate` covers Back and Forward.
*/
export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory {
const listeners = new Set<() => void>();
const notify = (): void => {
for (const listener of [...listeners]) {
listener();
}
};
return {
pathname: () => view.location.pathname,
push(path) {
if (path === view.location.pathname) {
return;
}
view.history.pushState(null, "", path);
notify();
},
replace(path) {
view.history.replaceState(null, "", path);
notify();
},
subscribe(listener) {
if (listeners.size === 0) {
view.addEventListener("popstate", notify);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
view.removeEventListener("popstate", notify);
}
};
},
};
}
/** An in-memory history with the same semantics, whose entries a caller can inspect and step
* through. Used by tests; production always uses {@link createBrowserHostHistory}. */
export interface MemoryHostHistory extends HostHistory {
readonly entries: readonly string[];
readonly index: number;
/** Steps like the browser's Back (`-1`) and Forward (`1`) buttons. */
go(delta: number): void;
}
export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory {
const entries: string[] = [initialPath];
let index = 0;
const listeners = new Set<() => void>();
const notify = (): void => {
for (const listener of [...listeners]) {
listener();
}
};
return {
get entries() {
return [...entries];
},
get index() {
return index;
},
pathname: () => entries[index] ?? "/",
push(path) {
if (path === entries[index]) {
return;
}
entries.splice(index + 1, entries.length, path);
index = entries.length - 1;
notify();
},
replace(path) {
entries[index] = path;
notify();
},
go(delta) {
const next = Math.min(Math.max(index + delta, 0), entries.length - 1);
if (next !== index) {
index = next;
notify();
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
+15 -2
View File
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import { consumeAuthLanding } from "../auth-protocol.js"; import { consumeAuthLanding } from "../auth-protocol.js";
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js"; import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
import { HostApp } from "./HostApp.js"; import { HostApp } from "./HostApp.js";
import { createBrowserHostHistory } from "./host-history.js";
/** /**
* The one place the shell touches a browser global. * The one place the shell touches a browser global.
@@ -23,6 +24,13 @@ const apiBaseUrl = resolveApiOrigin(
* `consumeAuthLanding` for why the token must not stay in the URL. */ * `consumeAuthLanding` for why the token must not stay in the URL. */
const authLanding = consumeAuthLanding(window.location, window.history); const authLanding = consumeAuthLanding(window.location, window.history);
/** The address bar, handed to the shell once so no other file reads `window.location` (ADR 0057). */
const history = createBrowserHostHistory(window);
const setDocumentTitle = (title: string): void => {
document.title = title;
};
const container = document.getElementById("ekklesia-host"); const container = document.getElementById("ekklesia-host");
if (!container) { if (!container) {
throw new Error("The Ekklesia shell requires a #ekklesia-host element."); throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
@@ -53,9 +61,14 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
); );
} }
return authLanding ? ( return authLanding ? (
<HostApp authLanding={authLanding} config={config} /> <HostApp
authLanding={authLanding}
config={config}
history={history}
setDocumentTitle={setDocumentTitle}
/>
) : ( ) : (
<HostApp config={config} /> <HostApp config={config} history={history} setDocumentTitle={setDocumentTitle} />
); );
} }
+89
View File
@@ -0,0 +1,89 @@
/**
* The Dashboard's URL space (ADR 0057): what each Dashboard path means.
*
* Framework-free and free of browser globals, because two programs read it. The shell parses the
* address bar with it, and `apps/api` serves the Dashboard page at the platform pages it lists, so a
* refreshed or bookmarked deep link reaches the shell instead of a `404`.
*
* Module pages and platform pages share one top-level namespace:
*
* ```text
* / Overview
* /settings[/…] Settings
* /account[/…] the account area
* /<module-id>[/…] a module's page, and later its own sub-pages
* ```
*
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
* Dashboard UI, and Core refuses any module whose id is a platform-owned name (`RESERVED_MODULE_IDS`
* in `@ekklesia/core/modules`), so a module page can never shadow a platform page and an arbitrary
* root path never becomes one.
*/
/**
* The Dashboard's own top-level pages besides the Overview. Reserved whole, including paths no page
* uses yet, so adding a Settings or Account page needs no server or proxy change. Each is also a
* reserved module id in Core; `apps/api` tests hold the two lists together.
*/
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const);
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
export type DashboardRoute =
| { readonly kind: "overview" }
| { readonly kind: "module"; readonly moduleId: string }
| { readonly kind: "not-found" };
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
function decodeSegment(segment: string): string | undefined {
try {
const decoded = decodeURIComponent(segment);
return decoded.length > 0 ? decoded : undefined;
} catch {
return undefined;
}
}
/**
* Reads a path into a route.
*
* A single top-level segment that is not a platform page names a module's page. The id is only
* decoded here, never validated against a pattern: the shell compares it with the navigation the
* server resolved, and an id that matches no entry is an unavailable module, not a malformed URL.
*
* Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), which
* need a contract that does not exist yet, so it is `not-found` for now rather than silently showing
* the module's first page. Settings and Account pages are likewise `not-found` until they exist. One
* trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the
* canonical spelling.
*/
export function parseDashboardPath(pathname: string): DashboardRoute {
if (pathname === "/") {
return { kind: "overview" };
}
const segments = pathname.split("/");
if (segments[0] !== "") {
return { kind: "not-found" };
}
const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1);
const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]);
if (first === undefined || platformPages.has(first)) {
return { kind: "not-found" };
}
if (meaningful.length === 1) {
return { kind: "module", moduleId: first };
}
return { kind: "not-found" };
}
/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
* own path segment. */
export function dashboardPath(route: DashboardDestination): string {
if (route.kind === "overview") {
return "/";
}
return `/${encodeURIComponent(route.moduleId)}`;
}
+196 -18
View File
@@ -1,6 +1,6 @@
import { designToken } from "@ekklesia/design-tokens"; import { designToken } from "@ekklesia/design-tokens";
import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useEffect, useState } from "react"; import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
import { import {
bootstrapDisplayHost, bootstrapDisplayHost,
type DisplayHostBootstrap, type DisplayHostBootstrap,
@@ -17,7 +17,13 @@ import { PairingScreen } from "./PairingScreen.js";
type DisplayState = type DisplayState =
| { readonly status: "loading"; readonly generation: number } | { readonly status: "loading"; readonly generation: number }
| { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } } | {
readonly status: "ready";
readonly bootstrap: DisplayHostBootstrap & { status: "ready" };
/** Changes only when the mounted display must be replaced, never merely because the network
* came back. It keys the frame, so it is the one thing that can remount a running display. */
readonly generation: number;
}
/** No identity this display can use yet: show a pairing code rather than a human login form. */ /** No identity this display can use yet: show a pairing code rather than a human login form. */
| { readonly status: "pairing" } | { readonly status: "pairing" }
| { readonly status: "unavailable" } | { readonly status: "unavailable" }
@@ -31,6 +37,69 @@ export interface DisplayAppProps {
/** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point /** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point
* supplies the real ones; omitting it leaves a display that simply never reports being offline. */ * supplies the real ones; omitting it leaves a display that simply never reports being offline. */
readonly connectivity?: DisplayConnectivitySource; readonly connectivity?: DisplayConnectivitySource;
/** How long the display must stay offline before the corner indicator appears. Test seam only. */
readonly offlineIndicatorDelayMs?: number;
/** The shortest interval between two revalidations prompted by refused module requests. Test seam
* only. */
readonly revalidationIntervalMs?: number;
/** How often a running display re-resolves its descriptor on its own. Test seam only. */
readonly periodicRevalidationMs?: number;
/** The first automatic retry delay after a failed start; doubles to a one-minute ceiling. Test seam
* only. */
readonly retryBaseMs?: number;
}
/** A Wi-Fi reassociation or a switch reboot lasts seconds and needs no comment on a lobby wall. Only
* an outage that outlasts this is worth the corner indicator. */
export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000;
/** A module whose requests are being refused prompts at most one revalidation in this interval. A
* display that is merely missing one permission must not turn every refused request into two more. */
export const DISPLAY_REVALIDATION_INTERVAL_MS = 15_000;
/** A running display asks again this often, so a module upgraded on the server is picked up and a
* revocation is noticed even by a module that makes no requests of its own. */
export const DISPLAY_PERIODIC_REVALIDATION_MS = 5 * 60_000;
/** A display that could not start tries again on its own: from this delay, doubling to a minute. A
* kiosk that boots before its server — after a power cut, say — must come up without anyone pressing
* Retry. */
export const DISPLAY_RETRY_BASE_MS = 2_000;
export const DISPLAY_RETRY_MAX_MS = 60_000;
/** Whether a fresh resolution describes exactly the display already mounted. Compared structurally:
* the descriptor and principal are small, server-produced, and carry nothing volatile, so any
* difference — a new version, a new contribution, a different principal — is a real one. */
function sameDisplay(left: DisplayHostBootstrap, right: DisplayHostBootstrap): boolean {
return (
JSON.stringify([left.descriptor, left.principal]) ===
JSON.stringify([right.descriptor, right.principal])
);
}
/** `value`, but only once it has been continuously true for `delayMs`. Becoming false is immediate. */
function useSustained(value: boolean, delayMs: number): boolean {
const [sustained, setSustained] = useState(value && delayMs <= 0);
useEffect(() => {
if (!value) {
setSustained(false);
return;
}
if (delayMs <= 0) {
setSustained(true);
return;
}
const timer = setTimeout(() => setSustained(true), delayMs);
return () => clearTimeout(timer);
}, [delayMs, value]);
return sustained;
}
function isDenied(error: unknown): boolean {
return (
error instanceof HostProtocolError &&
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
);
} }
const displayStyles = { const displayStyles = {
@@ -86,11 +155,13 @@ const displayStyles = {
* recovers on its own the moment connectivity returns. * recovers on its own the moment connectivity returns.
*/ */
function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement { function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement {
// Neutral wording: this is on a wall the congregation reads, and the product name is the
// installation's to choose, not the platform's (ADR 0046).
const label = props.connectivity.usingFallback const label = props.connectivity.usingFallback
? props.connectivity.fallbackKind === "artifact" ? props.connectivity.fallbackKind === "artifact"
? "Offline — showing the last downloaded display code" ? "Offline — showing the last downloaded display code"
: "Offline — showing the last downloaded Ekklesia display" : "Offline — showing the last downloaded display"
: "Offline — waiting for Ekklesia"; : "Offline — reconnecting";
return ( return (
<div data-testid="display-offline" role="status" style={displayStyles.offline}> <div data-testid="display-offline" role="status" style={displayStyles.offline}>
{label} {label}
@@ -130,7 +201,112 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
online: true, online: true,
usingFallback: false, usingFallback: false,
}); });
const { config, connectivity: connectivitySource, importModule, moduleId } = props; const {
config,
connectivity: connectivitySource,
importModule,
moduleId,
offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS,
periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS,
retryBaseMs = DISPLAY_RETRY_BASE_MS,
revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS,
} = props;
/** Consecutive automatic retries since the display last started successfully. */
const failures = useRef(0);
const stateRef = useRef(state);
stateRef.current = state;
const revalidation = useRef<{ inFlight: AbortController | undefined; lastAt: number }>({
inFlight: undefined,
lastAt: 0,
});
/**
* Resolves the descriptor again **without** taking the running display down (ADR 0043).
*
* A display that is showing something keeps showing it while the question is asked. Only the
* answer decides what happens: the same display means nothing changes at all; a new version or a
* different principal replaces the frame; a refusal is a revoked or expired device and returns the
* screen to pairing (ADR 0036); and any other failure is the network still settling, so the frame
* stays. A display that is not showing anything simply starts over.
*/
const revalidate = useCallback(
(reason: "online" | "authorization" | "periodic"): void => {
const current = stateRef.current;
if (current.status !== "ready") {
if (reason === "online") {
setAttempt((value) => value + 1);
}
return;
}
const tracker = revalidation.current;
const nowMs = Date.now();
if (
tracker.inFlight !== undefined ||
(reason === "authorization" && nowMs - tracker.lastAt < revalidationIntervalMs)
) {
return;
}
tracker.lastAt = nowMs;
const abort = new AbortController();
tracker.inFlight = abort;
void bootstrapDisplayHost(config, moduleId, abort.signal)
.then((next) => {
const latest = stateRef.current;
if (abort.signal.aborted || latest.status !== "ready") {
return;
}
if (!sameDisplay(latest.bootstrap, next)) {
setState({ bootstrap: next, generation: latest.generation + 1, status: "ready" });
}
})
.catch((error: unknown) => {
if (!abort.signal.aborted && isDenied(error)) {
setState({ status: "pairing" });
}
})
.finally(() => {
if (tracker.inFlight === abort) {
tracker.inFlight = undefined;
}
});
},
[config, moduleId, revalidationIntervalMs],
);
useEffect(
() => () => {
revalidation.current.inFlight?.abort();
revalidation.current.inFlight = undefined;
},
[],
);
const onAuthorizationResponse = useCallback(() => revalidate("authorization"), [revalidate]);
// While running: ask again now and then, without disturbing anything when the answer is the same.
useEffect(() => {
if (state.status !== "ready" || periodicRevalidationMs <= 0) {
return;
}
const timer = setInterval(() => revalidate("periodic"), periodicRevalidationMs);
return () => clearInterval(timer);
}, [periodicRevalidationMs, revalidate, state.status]);
// Could not start: try again on its own, backing off. Pairing is not a failure — it has its own
// polling — and a ready display has nothing to retry.
useEffect(() => {
if (state.status === "ready") {
failures.current = 0;
return;
}
if (state.status !== "failed" && state.status !== "unavailable") {
return;
}
const delay = Math.min(DISPLAY_RETRY_MAX_MS, retryBaseMs * 2 ** Math.min(failures.current, 10));
failures.current += 1;
const timer = setTimeout(() => setAttempt((value) => value + 1), delay);
return () => clearTimeout(timer);
}, [retryBaseMs, state.status]);
useEffect(() => { useEffect(() => {
if (!connectivitySource) { if (!connectivitySource) {
@@ -142,27 +318,29 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
setConnectivity(next); setConnectivity(next);
// Nobody presses Retry on a lobby television. When the browser says the network is back, the // Nobody presses Retry on a lobby television. When the browser says the network is back, the
// display resolves its descriptor again — which is also what stops a screen from sitting on // display resolves its descriptor again — which is also what stops a screen from sitting on
// cached code after the server returns, since the fresh descriptor names the current version // cached code after the server returns, since a fresh descriptor naming a new version replaces
// and the network-first worker fetches it (ADR 0043). // the frame and the network-first worker fetches it (ADR 0043). The same answer leaves the
// running display exactly where it was.
if (next.online) { if (next.online) {
setAttempt((current) => current + 1); revalidate("online");
} }
}); });
return () => { return () => {
unsubscribe(); unsubscribe();
monitor.close(); monitor.close();
}; };
}, [connectivitySource]); }, [connectivitySource, revalidate]);
useEffect(() => { useEffect(() => {
const abort = new AbortController(); const abort = new AbortController();
revalidation.current.inFlight?.abort();
setState({ generation: attempt, status: "loading" }); setState({ generation: attempt, status: "loading" });
void bootstrapDisplayHost(config, moduleId, abort.signal) void bootstrapDisplayHost(config, moduleId, abort.signal)
.then((bootstrap) => { .then((bootstrap) => {
if (abort.signal.aborted) { if (abort.signal.aborted) {
return; return;
} }
setState({ bootstrap, status: "ready" }); setState({ bootstrap, generation: attempt, status: "ready" });
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (abort.signal.aborted) { if (abort.signal.aborted) {
@@ -170,10 +348,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
} }
// A denied or unauthenticated descriptor is the ordinary state of a screen nobody has // A denied or unauthenticated descriptor is the ordinary state of a screen nobody has
// paired yet, not an error worth logging on every wall in the building. // paired yet, not an error worth logging on every wall in the building.
if ( if (isDenied(error)) {
error instanceof HostProtocolError &&
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
) {
setState({ status: "pairing" }); setState({ status: "pairing" });
return; return;
} }
@@ -188,12 +363,14 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
}, [attempt, config, moduleId]); }, [attempt, config, moduleId]);
const retry = (): void => setAttempt((current) => current + 1); const retry = (): void => setAttempt((current) => current + 1);
const showOffline = useSustained(
!connectivity.online || connectivity.usingFallback,
offlineIndicatorDelayMs,
);
return ( return (
<div data-testid="display-host" style={displayStyles.host}> <div data-testid="display-host" style={displayStyles.host}>
{connectivity.online && !connectivity.usingFallback ? undefined : ( {showOffline ? <OfflineIndicator connectivity={connectivity} /> : undefined}
<OfflineIndicator connectivity={connectivity} />
)}
{state.status === "loading" ? ( {state.status === "loading" ? (
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage> <DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
) : undefined} ) : undefined}
@@ -215,7 +392,8 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
config={config} config={config}
descriptor={state.bootstrap.descriptor} descriptor={state.bootstrap.descriptor}
{...(importModule ? { importModule } : {})} {...(importModule ? { importModule } : {})}
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`} key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${state.generation}`}
onAuthorizationResponse={onAuthorizationResponse}
principal={state.bootstrap.principal} principal={state.bootstrap.principal}
/> />
) : undefined} ) : undefined}
+12 -1
View File
@@ -35,6 +35,9 @@ export interface DisplayUiFrameProps {
readonly principal: ModuleUiPrincipal; readonly principal: ModuleUiPrincipal;
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */ /** Test/composition seam only. Production uses the generic loader's native dynamic import. */
readonly importModule?: ModuleUiImporter; readonly importModule?: ModuleUiImporter;
/** Host-internal signal that a module request was refused, so the host can find out whether this
* device was revoked. It never enters the module-facing context. */
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
} }
interface DisplayUiAttemptProps extends DisplayUiFrameProps { interface DisplayUiAttemptProps extends DisplayUiFrameProps {
@@ -94,7 +97,10 @@ const frameStyles = {
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement { function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<FrameStatus>("loading"); const [status, setStatus] = useState<FrameStatus>("loading");
const { config, descriptor, importModule, onRetry, principal } = props; const { config, descriptor, importModule, onAuthorizationResponse, onRetry, principal } = props;
// Read through a ref so a new callback identity can never remount a running display.
const authorizationRef = useRef(onAuthorizationResponse);
authorizationRef.current = onAuthorizationResponse;
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -162,6 +168,11 @@ function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
context: createModuleUiContext({ context: createModuleUiContext({
config, config,
descriptor, descriptor,
onAuthorizationResponse: (status) => {
if (!cancelled) {
authorizationRef.current?.(status);
}
},
principal, principal,
...(importModule ? { importModule } : {}), ...(importModule ? { importModule } : {}),
}), }),
+8 -1
View File
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js"; import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { HostApp } from "../src/browser/HostApp.js"; import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js"; import type { EkklesiaHostConfig } from "../src/host-protocol.js";
/** /**
@@ -94,7 +95,13 @@ function platform(stub: AuthStub = {}): Platform {
async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> { async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
await act(async () => { await act(async () => {
root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />); root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/")}
{...(authLanding ? { authLanding } : {})}
/>,
);
}); });
} }
@@ -0,0 +1,94 @@
// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
DASHBOARD_PLATFORM_PAGES,
dashboardPath,
parseDashboardPath,
} from "../src/dashboard-routes.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("the Dashboard's own top-level pages", () => {
it("are exactly Settings and Account, frozen", () => {
expect(Object.isFrozen(DASHBOARD_PLATFORM_PAGES)).toBe(true);
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
});
});
describe("parseDashboardPath", () => {
it("makes the root the Overview", () => {
expect(parseDashboardPath("/")).toEqual({ kind: "overview" });
});
it("names a module page by its id at the top level, tolerating one trailing slash", () => {
expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
});
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" });
});
it("never reads a platform page as a module page", () => {
for (const pathname of ["/settings", "/settings/", "/account", "/account/appearance"]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
it("treats every other shape as not found rather than guessing", () => {
for (const pathname of [
"",
"//",
"//signage",
"/signage//",
// A module's own sub-pages need a contract that does not exist yet (ADR 0057).
"/signage/timelines",
"/signage/timelines/abc",
"/%E0%A4%A",
"/settings/displays/add",
"signage",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
});
describe("dashboardPath", () => {
it("spells each route canonically, with no implementation prefix", () => {
expect(dashboardPath({ kind: "overview" })).toBe("/");
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/signage");
});
it("encodes a module id so it can never escape its own segment", () => {
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb");
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
kind: "module",
moduleId: "a/../b",
});
});
it("round-trips through the parser", () => {
for (const route of [
{ kind: "overview" } as const,
{ kind: "module", moduleId: "example" } as const,
]) {
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
}
});
});
describe("the route locator is published for the API that serves the page", () => {
it("is a narrow package subpath, not the shell root barrel", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { exports?: Record<string, unknown> };
expect(manifest.exports?.["./dashboard-routes"]).toEqual({
import: "./dist/dashboard-routes.js",
types: "./dist/dashboard-routes.d.ts",
});
});
});
+106
View File
@@ -0,0 +1,106 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DevicePanel } from "../src/browser/DevicePanel.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
function server() {
let revoked = false;
const revokes: string[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/devices")) {
return json({
devices: [{ id: "device-1", name: "Foyer TV", permissions: [], revoked }],
});
}
if (url.endsWith("/api/v1/devices/device-1/revoke")) {
revoked = true;
revokes.push(url);
return new Response(null, { status: 204 });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { fetch, revokes };
}
function query(testId: string): HTMLElement | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
async function click(testId: string): Promise<void> {
await act(async () => {
query(testId)?.click();
for (let index = 0; index < 5; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
/**
* Revoking a display strands that screen until someone re-pairs it in person, so the panel explains
* the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21).
*/
describe("display device revocation", () => {
it("asks in a product dialog before revoking, and does nothing on cancel", async () => {
const platform = server();
const confirm = vi.fn(() => true);
Object.assign(window, { confirm });
await act(async () => {
root.render(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
});
await click("device-revoke-device-1");
const dialog = container.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Revoke Foyer TV?");
expect(dialog?.textContent).toContain("pairing code");
expect(platform.revokes).toEqual([]);
await click("device-revoke-cancel");
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(platform.revokes).toEqual([]);
expect(confirm).not.toHaveBeenCalled();
});
it("revokes only after the destructive action is confirmed", async () => {
const platform = server();
await act(async () => {
root.render(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
});
await click("device-revoke-device-1");
await click("device-revoke-confirm");
expect(platform.revokes).toHaveLength(1);
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(query("device-device-1")?.textContent).toContain("(revoked)");
});
});
+311 -1
View File
@@ -3,6 +3,7 @@ import { act, Component, type ReactElement, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js"; import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js";
import { DisplayApp } from "../src/display/DisplayApp.js"; import { DisplayApp } from "../src/display/DisplayApp.js";
import type { import type {
EkklesiaHostConfig, EkklesiaHostConfig,
@@ -180,6 +181,12 @@ async function render(
importModule?: (url: string) => Promise<unknown>, importModule?: (url: string) => Promise<unknown>,
moduleId = "announcements", moduleId = "announcements",
connectivity?: DisplayConnectivitySource, connectivity?: DisplayConnectivitySource,
timing: {
readonly offlineIndicatorDelayMs?: number;
readonly revalidationIntervalMs?: number;
} = {
offlineIndicatorDelayMs: 0,
},
): Promise<void> { ): Promise<void> {
await act(async () => { await act(async () => {
root.render( root.render(
@@ -187,6 +194,7 @@ async function render(
config={config} config={config}
{...(connectivity ? { connectivity } : {})} {...(connectivity ? { connectivity } : {})}
{...(importModule ? { importModule } : {})} {...(importModule ? { importModule } : {})}
{...timing}
moduleId={moduleId} moduleId={moduleId}
/>, />,
); );
@@ -386,7 +394,15 @@ describe("display host — full-viewport boundary", () => {
}); });
}; };
await act(async () => root.render(<HostApp config={config} importModule={importer} />)); await act(async () =>
root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/announcements")}
importModule={importer}
/>,
),
);
// The management artifact rendered inside its own isolated root, not the shell's own tree. // The management artifact rendered inside its own isolated root, not the shell's own tree.
expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain( expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain(
"announcement management", "announcement management",
@@ -926,3 +942,297 @@ describe("display host — offline behaviour", () => {
expect(query("display-pairing")).not.toBeNull(); expect(query("display-pairing")).not.toBeNull();
}); });
}); });
/**
* Stage 0 regressions: a running display survives its network, and a revoked one returns to pairing.
*
* Before these, every browser `online` event tore the module down and mounted it again (a fresh
* viewer session, a fresh stage, every cached object URL released), and a device revoked while its
* display was open kept playing until someone reloaded the page.
*/
describe("display host — continuity across reconnects and revocation", () => {
function controllableConnectivity() {
const listeners = new Map<string, Set<() => void>>();
return {
source: {
addEventListener(type: "online" | "offline", listener: () => void) {
const existing = listeners.get(type) ?? new Set();
existing.add(listener);
listeners.set(type, existing);
},
initialOnline: true,
removeEventListener(type: "online" | "offline", listener: () => void) {
listeners.get(type)?.delete(listener);
},
} satisfies DisplayConnectivitySource,
async emit(type: "online" | "offline") {
await act(async () => {
for (const listener of listeners.get(type) ?? []) {
listener();
}
});
},
};
}
async function flush(): Promise<void> {
await act(async () => {
for (let index = 0; index < 5; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
/** A server whose descriptor answer can be changed mid-test, counting descriptor requests. */
function changingServer() {
let descriptor: { status: number; body: unknown } = { body: descriptorPayload(), status: 200 };
let descriptorCalls = 0;
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/module-displays/announcements")) {
descriptorCalls += 1;
return json(descriptor.body, descriptor.status);
}
if (url.endsWith("/api/v1/device-pairing/requests")) {
return json(
{ code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 },
201,
);
}
if (url.endsWith("/api/v1/device-pairing/claims")) {
return json({ status: "pending" });
}
if (url.endsWith("/api/v1/modules/announcements/refused")) {
return json({ code: "AUTHORIZATION_DENIED" }, 403);
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return {
descriptorCalls: () => descriptorCalls,
fetch,
set(status: number, body: unknown) {
descriptor = { body, status };
},
};
}
function countingArtifact(version = "1.2.0") {
const counts = { mounts: 0, unmounts: 0 };
let context: ModuleUiHostContext | undefined;
const importModule = async (url: string) =>
artifact("announcements", url.includes("/2.0.0/") ? "2.0.0" : version, {
mount(element, mounted) {
counts.mounts += 1;
context = mounted;
element.textContent = "live";
return () => {
counts.unmounts += 1;
};
},
});
return { context: () => context, counts, importModule };
}
it("does not remount a live display when the network drops and returns", async () => {
const server = changingServer();
const connectivity = controllableConnectivity();
const display = countingArtifact();
await render(
{ apiBaseUrl, fetch: server.fetch },
display.importModule,
"announcements",
connectivity.source,
);
await flush();
expect(display.counts.mounts).toBe(1);
const before = server.descriptorCalls();
await connectivity.emit("offline");
await connectivity.emit("online");
await flush();
// The descriptor is still resolved again (ADR 0043) — but the same answer changes nothing.
expect(server.descriptorCalls()).toBeGreaterThan(before);
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
expect(query("display-host-loading")).toBeNull();
});
it("keeps the running display when the descriptor cannot be reached yet", async () => {
const server = changingServer();
const connectivity = controllableConnectivity();
const display = countingArtifact();
await render(
{ apiBaseUrl, fetch: server.fetch },
display.importModule,
"announcements",
connectivity.source,
);
await flush();
server.set(503, { code: "UNAVAILABLE" });
await connectivity.emit("offline");
await connectivity.emit("online");
await flush();
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
expect(query("display-host-error")).toBeNull();
});
it("replaces the display when the server now names a new version", async () => {
const server = changingServer();
const connectivity = controllableConnectivity();
const display = countingArtifact();
await render(
{ apiBaseUrl, fetch: server.fetch },
display.importModule,
"announcements",
connectivity.source,
);
await flush();
server.set(200, descriptorPayload("announcements", "2.0.0"));
await connectivity.emit("offline");
await connectivity.emit("online");
await flush();
expect(display.counts.unmounts).toBe(1);
expect(display.counts.mounts).toBe(2);
});
it("returns a device revoked while its display is open to pairing, without a reload", async () => {
const server = changingServer();
const display = countingArtifact();
await render({ apiBaseUrl, fetch: server.fetch }, display.importModule, "announcements");
await flush();
expect(query("display-module-container")).not.toBeNull();
// The administrator revokes the device. Its next module request is refused.
server.set(403, { code: "AUTHORIZATION_DENIED" });
await act(async () => {
await display.context()?.api.request("refused");
});
await flush();
expect(display.counts.unmounts).toBe(1);
expect(query("display-pairing")).not.toBeNull();
});
it("does not remount when a refused request turns out not to be a revocation", async () => {
const server = changingServer();
const display = countingArtifact();
await render(
{ apiBaseUrl, fetch: server.fetch },
display.importModule,
"announcements",
undefined,
{
offlineIndicatorDelayMs: 0,
revalidationIntervalMs: 60_000,
},
);
await flush();
const before = server.descriptorCalls();
await act(async () => {
await display.context()?.api.request("refused");
await display.context()?.api.request("refused");
await display.context()?.api.request("refused");
});
await flush();
// One revalidation for the burst, and the display stays exactly where it was.
expect(server.descriptorCalls()).toBe(before + 1);
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
});
it("does not show the offline indicator for a momentary blip", async () => {
const server = changingServer();
const connectivity = controllableConnectivity();
await render(
{ apiBaseUrl, fetch: server.fetch },
countingArtifact().importModule,
"announcements",
connectivity.source,
{ offlineIndicatorDelayMs: 60 },
);
await flush();
await connectivity.emit("offline");
await connectivity.emit("online");
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 120));
});
expect(query("display-offline")).toBeNull();
await connectivity.emit("offline");
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 120));
});
expect(query("display-offline")?.textContent).toBe("Offline — reconnecting");
});
it("starts on its own when the server comes up after the screen did", async () => {
// A kiosk that boots before its server: the network is up, so no `online` event will ever come.
let up = false;
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/module-displays/announcements")) {
return up ? json(descriptorPayload()) : json({ code: "UNAVAILABLE" }, 503);
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
const display = countingArtifact();
await act(async () => {
root.render(
<DisplayApp
config={{ apiBaseUrl, fetch }}
importModule={display.importModule}
moduleId="announcements"
offlineIndicatorDelayMs={0}
retryBaseMs={20}
/>,
);
});
await flush();
expect(query("display-host-error")).not.toBeNull();
up = true;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
await flush();
expect(display.counts.mounts).toBe(1);
expect(query("display-host-error")).toBeNull();
});
it("re-resolves a running display periodically, remounting only for a new version", async () => {
const server = changingServer();
const display = countingArtifact();
await act(async () => {
root.render(
<DisplayApp
config={{ apiBaseUrl, fetch: server.fetch }}
importModule={display.importModule}
moduleId="announcements"
offlineIndicatorDelayMs={0}
periodicRevalidationMs={40}
/>,
);
});
await flush();
const before = server.descriptorCalls();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
await flush();
expect(server.descriptorCalls()).toBeGreaterThan(before);
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
server.set(200, descriptorPayload("announcements", "2.0.0"));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
await flush();
expect(display.counts.mounts).toBe(2);
});
});
+59 -9
View File
@@ -3,6 +3,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js"; import { HostApp } from "../src/browser/HostApp.js";
import { createBrowserHostHistory } from "../src/browser/host-history.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js"; import type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js"; import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
@@ -122,12 +123,27 @@ function artifact(id: string, version: string, options: ArtifactOptions = {}) {
}; };
} }
/**
* Renders the shell over the real (happy-dom) address bar. `path` opens that page first, exactly as
* a deep link or a refresh would (ADR 0057); left out, the shell opens wherever the location already
* is — `/`, the Overview, unless a test put something else there.
*/
async function render( async function render(
config: EkklesiaHostConfig, config: EkklesiaHostConfig,
importModule?: (url: string) => Promise<unknown>, importModule?: (url: string) => Promise<unknown>,
path?: string,
): Promise<void> { ): Promise<void> {
if (path !== undefined) {
window.history.replaceState(null, "", path);
}
await act(async () => { await act(async () => {
root.render(<HostApp config={config} {...(importModule ? { importModule } : {})} />); root.render(
<HostApp
config={config}
history={createBrowserHostHistory(window)}
{...(importModule ? { importModule } : {})}
/>,
);
}); });
} }
@@ -268,6 +284,11 @@ describe("Ekklesia shell — bootstrap", () => {
}); });
describe("Ekklesia shell — browser authentication", () => { describe("Ekklesia shell — browser authentication", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/example");
});
it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => { it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
let releasePrincipal: (() => void) | undefined; let releasePrincipal: (() => void) | undefined;
const principalPending = new Promise<void>((resolve) => { const principalPending = new Promise<void>((resolve) => {
@@ -539,6 +560,11 @@ describe("Ekklesia shell — browser authentication", () => {
}); });
describe("Ekklesia shell — logout and session expiration", () => { describe("Ekklesia shell — logout and session expiration", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/example");
});
it("unmounts module UI and closes realtime before completing server-side logout", async () => { it("unmounts module UI and closes realtime before completing server-side logout", async () => {
let releaseSignOut: (() => void) | undefined; let releaseSignOut: (() => void) | undefined;
const signOutPending = new Promise<void>((resolve) => { const signOutPending = new Promise<void>((resolve) => {
@@ -856,10 +882,8 @@ describe("Ekklesia shell — Dashboard navigation", () => {
const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent); const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent);
expect(labels).toEqual(["Planning", "Media"]); expect(labels).toEqual(["Planning", "Media"]);
const buttons = [...container.querySelectorAll("nav button")].map( const links = [...container.querySelectorAll("nav section a")].map((link) => link.textContent);
(button) => button.textContent, expect(links).toEqual(["Calendar", "Livestream", "Video"]);
);
expect(buttons).toEqual(["Calendar", "Livestream", "Video"]);
}); });
it("renders ungrouped modules alongside groups, under a heading", async () => { it("renders ungrouped modules alongside groups, under a heading", async () => {
@@ -897,14 +921,29 @@ describe("Ekklesia shell — Dashboard navigation", () => {
expect(query("group-media")?.textContent).toBe("Media"); expect(query("group-media")?.textContent).toBe("Media");
}); });
it("selects the first module in the arranged order and marks it active", async () => { it("opens on the Overview rather than choosing a module, whatever the arrangement", async () => {
const importModule = vi.fn(async () => artifact("video", "0.1.0"));
await render( await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) }, { apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async (url) => artifact(url.includes("/video/") ? "video" : "other", "0.1.0"), importModule,
); );
expect(query("select-video")?.getAttribute("aria-current")).toBe("page"); expect(query("select-overview")?.getAttribute("aria-current")).toBe("page");
expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull(); expect(query("select-video")?.getAttribute("aria-current")).toBeNull();
expect(query("module-container")).toBeNull();
expect(importModule).not.toHaveBeenCalled();
});
it("marks the open module's link as the current page", async () => {
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async () => artifact("livestream", "2.0.0"),
"/livestream",
);
expect(query("select-livestream")?.getAttribute("aria-current")).toBe("page");
expect(query("select-video")?.getAttribute("aria-current")).toBeNull();
expect(query("select-overview")?.getAttribute("aria-current")).toBeNull();
}); });
it("mounts a module selected from inside a group, using the descriptor the group carried", async () => { it("mounts a module selected from inside a group, using the descriptor the group carried", async () => {
@@ -915,6 +954,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
requested.push(url); requested.push(url);
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0"); return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
}, },
"/video",
); );
await act(async () => { await act(async () => {
@@ -972,6 +1012,11 @@ describe("Ekklesia shell — Dashboard navigation", () => {
}); });
describe("Ekklesia shell — mount lifecycle", () => { describe("Ekklesia shell — mount lifecycle", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/example");
});
it("keeps fixed shell tracks while module content changes size", async () => { it("keeps fixed shell tracks while module content changes size", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0")); await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0"));
@@ -1140,6 +1185,11 @@ describe("Ekklesia shell — mount lifecycle", () => {
}); });
describe("Ekklesia shell — failure is host-owned", () => { describe("Ekklesia shell — failure is host-owned", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/example");
});
it("renders its own error when the artifact cannot be imported", async () => { it("renders its own error when the artifact cannot be imported", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => { await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing"); throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing");
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createBrowserHostHistory, createMemoryHostHistory } from "../src/browser/host-history.js";
describe("the browser host history", () => {
beforeEach(() => {
window.history.replaceState(null, "", "/");
});
afterEach(() => {
window.history.replaceState(null, "", "/");
});
it("reads the address bar's path and moves it with real history entries", () => {
const history = createBrowserHostHistory(window);
const lengthBefore = window.history.length;
history.push("/example");
expect(history.pathname()).toBe("/example");
expect(window.location.pathname).toBe("/example");
expect(window.history.length).toBe(lengthBefore + 1);
});
it("adds no entry when the page asked for is already open", () => {
const history = createBrowserHostHistory(window);
history.push("/example");
const length = window.history.length;
const listener = vi.fn();
history.subscribe(listener);
history.push("/example");
expect(window.history.length).toBe(length);
expect(listener).not.toHaveBeenCalled();
});
it("corrects the current entry in place with replace", () => {
const history = createBrowserHostHistory(window);
history.push("/example/");
const length = window.history.length;
history.replace("/example");
expect(window.location.pathname).toBe("/example");
expect(window.history.length).toBe(length);
});
it("notifies after its own changes and after the browser's Back and Forward", () => {
const history = createBrowserHostHistory(window);
const listener = vi.fn();
const unsubscribe = history.subscribe(listener);
history.push("/a");
history.replace("/b");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(listener).toHaveBeenCalledTimes(3);
unsubscribe();
window.dispatchEvent(new PopStateEvent("popstate"));
history.push("/c");
expect(listener).toHaveBeenCalledTimes(3);
});
it("listens to popstate only while something is subscribed", () => {
const view = {
addEventListener: vi.fn(),
history: { pushState: vi.fn(), replaceState: vi.fn() },
location: { pathname: "/" },
removeEventListener: vi.fn(),
};
const history = createBrowserHostHistory(view);
const first = history.subscribe(() => undefined);
const second = history.subscribe(() => undefined);
expect(view.addEventListener).toHaveBeenCalledTimes(1);
first();
expect(view.removeEventListener).not.toHaveBeenCalled();
second();
expect(view.removeEventListener).toHaveBeenCalledTimes(1);
expect(view.removeEventListener.mock.calls[0]?.[1]).toBe(
view.addEventListener.mock.calls[0]?.[1],
);
});
it("stores no state object, so nothing a page put in history can be read back as authority", () => {
const view = {
addEventListener: vi.fn(),
history: { pushState: vi.fn(), replaceState: vi.fn() },
location: { pathname: "/" },
removeEventListener: vi.fn(),
};
const history = createBrowserHostHistory(view);
history.push("/a");
history.replace("/b");
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/a");
expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/b");
});
});
describe("the in-memory host history", () => {
it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => {
const history = createMemoryHostHistory("/");
history.push("/a");
history.push("/b");
history.go(-1);
expect(history.pathname()).toBe("/a");
history.push("/c");
expect(history.entries).toEqual(["/", "/a", "/c"]);
history.go(-10);
expect(history.pathname()).toBe("/");
history.go(10);
expect(history.pathname()).toBe("/c");
});
});
+360
View File
@@ -0,0 +1,360 @@
import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedText } from "./support/isolated-dom.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* The Dashboard's URL is the source of truth for which page is open (ADR 0057). Every test drives
* the real shell through an in-memory history, so it can assert on the exact entries a person's
* Back and Forward buttons would walk.
*/
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
});
function entry(id: string, name: string, version = "0.1.0") {
return {
module: {
api: { baseUrl: `/api/v1/modules/${id}` },
contributions: [],
display: null,
id,
permissions: [],
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
ui: { url: `/api/v1/module-ui/${id}/${version}` },
version,
},
name,
};
}
const NAVIGATION = {
groups: [{ id: "media", label: "Media", modules: [entry("signage", "Signage")], order: 10 }],
ungrouped: [entry("calendar", "Calendar", "2.0.0")],
};
function platform(options: { authenticated?: boolean } = {}) {
let authenticated = options.authenticated ?? true;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/auth/sign-in/email")) {
void init;
authenticated = true;
return Response.json({ ok: true });
}
if (url.endsWith("/api/v1/principal")) {
return Response.json(
authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
);
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return authenticated
? Response.json(NAVIGATION)
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
return Response.json({ code: "NOT_FOUND" }, { status: 404 });
}) as unknown as typeof fetch;
return fetchImplementation;
}
function artifactFor(url: string) {
const id = url.includes("/calendar/") ? "calendar" : "signage";
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id,
mount(element: Element, context: ModuleUiHostContext) {
element.textContent = `${context.moduleId} mounted`;
return () => {
element.textContent = "";
};
},
version: id === "calendar" ? "2.0.0" : "0.1.0",
},
};
}
interface Rendered {
readonly history: MemoryHostHistory;
readonly imported: string[];
readonly titles: string[];
}
async function render(
path: string,
options: { authenticated?: boolean; history?: MemoryHostHistory } = {},
): Promise<Rendered> {
const history = options.history ?? createMemoryHostHistory(path);
const imported: string[] = [];
const titles: string[] = [];
const config: EkklesiaHostConfig = { apiBaseUrl, fetch: platform(options) };
await act(async () => {
root.render(
<HostApp
config={config}
history={history}
importModule={async (url) => {
imported.push(url);
return artifactFor(url);
}}
setDocumentTitle={(title) => titles.push(title)}
/>,
);
});
return { history, imported, titles };
}
function query(testId: string): HTMLElement | null {
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
}
async function click(element: Element | null, init: MouseEventInit = {}): Promise<MouseEvent> {
if (!element) {
throw new Error("missing element");
}
const event = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init });
await act(async () => {
element.dispatchEvent(event);
});
return event;
}
async function go(history: MemoryHostHistory, delta: number): Promise<void> {
await act(async () => {
history.go(delta);
});
}
describe("the Dashboard opens on the page its URL names", () => {
it("lands on the Overview at the root, and mounts no module", async () => {
const { imported, titles } = await render("/");
expect(query("host-overview")).not.toBeNull();
expect(query("host-overview")?.querySelector("h1")?.textContent).toBe("Overview");
expect(query("module-container")).toBeNull();
expect(imported).toEqual([]);
expect(query("select-overview")?.getAttribute("aria-current")).toBe("page");
expect(titles.at(-1)).toBe("Overview");
});
it("opens a module's page directly from its deep link — what a refresh or a bookmark does", async () => {
const { imported, titles } = await render("/calendar");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
expect(query("select-calendar")?.getAttribute("aria-current")).toBe("page");
expect(query("select-overview")?.getAttribute("aria-current")).toBeNull();
expect(query("host-overview")).toBeNull();
expect(titles.at(-1)).toBe("Calendar");
});
it("opens a module from inside a group exactly as one outside it", async () => {
const { imported } = await render("/signage");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`]);
expect(query("select-signage")?.getAttribute("aria-current")).toBe("page");
});
it("says a module page is unavailable when no navigable module has that id, and loads nothing", async () => {
const { imported, titles } = await render("/disabled-module");
expect(query("host-module-unavailable")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(imported).toEqual([]);
// The address is never echoed back into the page.
expect(container.textContent).not.toContain("disabled-module");
expect(titles.at(-1)).toBe("This page isn’t available");
expect(query("host-go-to-overview")?.getAttribute("href")).toBe("/");
});
it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => {
for (const path of ["/settings/displays", "/calendar/events", "/account"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
const { imported, titles } = await render(path);
expect(query("host-not-found"), path).not.toBeNull();
expect(imported, path).toEqual([]);
expect(titles.at(-1), path).toBe("Page not found");
// Nothing in the navigation claims to be the open page.
expect(container.querySelector('[aria-current="page"]'), path).toBeNull();
}
});
it("addresses module pages at the top level, beside the platform's own pages, and never confuses the two", async () => {
// Core refuses a module whose id is a platform page, but even a navigation that somehow listed
// one could not turn `/settings` into a module page.
const { imported } = await render("/settings");
expect(query("host-not-found")).not.toBeNull();
expect(imported).toEqual([]);
expect(query("select-signage")?.getAttribute("href")).toBe("/signage");
expect(container.innerHTML).not.toContain("/modules/");
});
it("corrects a trailing slash to the canonical address in place, adding no history entry", async () => {
const { history, imported } = await render("/calendar/");
expect(history.entries).toEqual(["/calendar"]);
expect(imported).toHaveLength(1);
});
it("opens the deep link it was given once the person signs in, with no redirect", async () => {
const history = createMemoryHostHistory("/calendar");
const { imported } = await render("/calendar", { authenticated: false, history });
expect(query("host-login")).not.toBeNull();
const email = query("login-email") as HTMLInputElement;
const password = query("login-password") as HTMLInputElement;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
await act(async () => {
setter?.call(email, "[email protected]");
email.dispatchEvent(new Event("input", { bubbles: true }));
setter?.call(password, "correct password");
password.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
query("host-login")
?.querySelector("form")
?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(history.entries).toEqual(["/calendar"]);
});
});
describe("moving between pages", () => {
it("renders navigation as real links to canonical addresses", async () => {
await render("/");
for (const [testId, href] of [
["select-overview", "/"],
["select-signage", "/signage"],
["select-calendar", "/calendar"],
] as const) {
const link = query(testId);
expect(link?.tagName, testId).toBe("A");
expect(link?.getAttribute("href"), testId).toBe(href);
}
expect(container.querySelector('nav[aria-label="Dashboard"]')).not.toBeNull();
});
it("opens a page in place on an ordinary click, adding one history entry", async () => {
const { history, imported } = await render("/");
const event = await click(query("select-calendar"));
expect(event.defaultPrevented).toBe(true);
expect(history.entries).toEqual(["/", "/calendar"]);
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
});
it("leaves a modified or middle click to the browser, so a page can open in a new tab", async () => {
const { history } = await render("/");
for (const init of [
{ ctrlKey: true },
{ metaKey: true },
{ shiftKey: true },
{ altKey: true },
{ button: 1 },
]) {
const event = await click(query("select-calendar"), init);
expect(event.defaultPrevented, JSON.stringify(init)).toBe(false);
}
expect(history.entries).toEqual(["/"]);
});
it("adds no entry when the open page's own link is clicked again", async () => {
const { history } = await render("/calendar");
await click(query("select-calendar"));
expect(history.entries).toEqual(["/calendar"]);
});
it("walks Back and Forward through exactly the pages that were open", async () => {
const { history, imported } = await render("/");
await click(query("select-signage"));
await click(query("select-calendar"));
await go(history, -1);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
expect(query("select-signage")?.getAttribute("aria-current")).toBe("page");
await go(history, -1);
expect(query("host-overview")).not.toBeNull();
expect(query("module-container")).toBeNull();
await go(history, 1);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
expect(imported).toEqual([
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`,
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
]);
});
it("returns to the Overview from an unavailable page through its own link", async () => {
const { history } = await render("/missing");
await click(query("host-go-to-overview"));
expect(history.entries).toEqual(["/missing", "/"]);
expect(query("host-overview")).not.toBeNull();
});
it("moves focus to the new page's content after a page change, but not on first load", async () => {
await render("/");
const main = container.querySelector("main");
expect(main?.getAttribute("tabindex")).toBe("-1");
expect(document.activeElement).not.toBe(main);
await click(query("select-calendar"));
expect(document.activeElement).toBe(main);
expect(main?.getAttribute("aria-label")).toBe("Calendar");
});
it("names the browser tab after each page as it opens", async () => {
const { titles } = await render("/");
await click(query("select-signage"));
await click(query("select-overview"));
expect(titles).toEqual(["Overview", "Signage", "Overview"]);
});
});
+1
View File
@@ -11,6 +11,7 @@
"include": [ "include": [
"src/index.ts", "src/index.ts",
"src/dashboard-assets.ts", "src/dashboard-assets.ts",
"src/dashboard-routes.ts",
"src/display-assets.ts", "src/display-assets.ts",
"src/host-protocol.ts", "src/host-protocol.ts",
"src/module-context.ts" "src/module-context.ts"
+83 -6
View File
@@ -128,6 +128,34 @@ describe("CI scripts on a shared Docker daemon", () => {
expect(workflow.jobs["deploy-smoke"]?.env?.EKKLESIA_CI_JOB_KEY).toContain("matrix.topology"); expect(workflow.jobs["deploy-smoke"]?.env?.EKKLESIA_CI_JOB_KEY).toContain("matrix.topology");
}); });
it("never sweep another run's live stack early, not even from the sweep's own self-test", async () => {
// The workflow's stale sweeps keep the default threshold, which is far above any job's run time.
const workflow = await source(".github/workflows/ci.yml");
const workflowSweeps = workflow
.split("\n")
.filter((line) => line.includes("ci-sweep.sh --stale"));
expect(workflowSweeps.length).toBeGreaterThan(0);
for (const line of workflowSweeps) {
expect(line).not.toContain("--max-age-seconds");
}
// The self-test does use short thresholds, so every stale sweep it runs is confined to its own
// fake run. Unconfined, `--max-age-seconds 0` removed every CI stack on the shared daemon —
// including a module repository's integration stack mid-run.
const selfTest = await source("scripts/test-ci-sweep.sh");
expect(selfTest).toMatch(/^own_run="\$\(ci_name_safe "ekklesia-ci-\$\{GITHUB_RUN_ID\}-"\)"$/mu);
expect(selfTest.indexOf('export GITHUB_RUN_ID="sweeptest')).toBeLessThan(
selfTest.indexOf("own_run="),
);
const selfTestSweeps = selfTest
.split("\n")
.filter((line) => /^\s*bash scripts\/ci-sweep\.sh --stale/u.test(line));
expect(selfTestSweeps.length).toBeGreaterThan(0);
for (const line of selfTestSweeps) {
expect(line).toContain('--prefix "$own_run"');
}
});
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => { it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
const deploy = await source("scripts/smoke-deploy.sh"); const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu); expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
@@ -157,6 +185,21 @@ describe("CI scripts on a shared Docker daemon", () => {
} }
}); });
it("exercise a real native package through the hardened verifier container", async () => {
const imageCheck = (await source("scripts/check-image.sh")).replace(/\\\n\s*/gu, " ");
expect(imageCheck).toContain(
"packages/module-pack/tests/support/pack-native-verifier-proof.mjs",
);
expect(imageCheck).toContain("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m");
expect(imageCheck).toContain(
"--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000",
);
expect(imageCheck).toContain("const actual=await m.proof()");
expect(imageCheck).toContain("file:///tmp/noexec-installed/server/index.js");
expect(imageCheck).toContain("scratch_failure /tmp");
expect(imageCheck).toContain("scratch_failure /package");
});
it("install the smoke test's module only as a canonical package the image itself verified (ADR 0049)", async () => { it("install the smoke test's module only as a canonical package the image itself verified (ADR 0049)", async () => {
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " "); const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
const packaging = await source("scripts/lib/build-module-package.Dockerfile"); const packaging = await source("scripts/lib/build-module-package.Dockerfile");
@@ -164,19 +207,28 @@ describe("CI scripts on a shared Docker daemon", () => {
expect(packaging).not.toMatch(/pnpm[^\n]*\bdeploy\b/u); expect(packaging).not.toMatch(/pnpm[^\n]*\bdeploy\b/u);
expect(deploy).toContain("scripts/lib/build-module-package.Dockerfile"); expect(deploy).toContain("scripts/lib/build-module-package.Dockerfile");
expect(deploy).toMatch( expect(deploy).toMatch(
/--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to \/staging\/example/u, /--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to "\/staging\/\$\{module_id\}"/u,
); );
expect(deploy).toContain("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m");
expect(deploy).toContain(
"--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000",
);
expect(deploy).toContain("-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier");
// Module storage only ever receives the verified tree from staging. // Module storage only ever receives the verified tree from staging.
expect(deploy).toContain("cp -R /staging/example /modules/.incoming-example"); expect(deploy).toContain(`staged="/staging/\${MODULE_ID}"`);
expect(deploy).toContain('cp -R "$staged" "$incoming"');
expect(deploy).toContain('mv "$incoming" "$installed"');
}); });
it("hand the verifier the package's archive and digest together, from a fresh release pair, never from build output", async () => { it("hand the verifier the package's archive and digest together, from a fresh release pair, never from build output", async () => {
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " "); const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
expect(deploy).toContain('source "$repository_root/scripts/lib/module-package-release.sh"'); expect(deploy).toContain('source "$repository_root/scripts/lib/module-package-release.sh"');
// The pair is made from this run's own build output, into a directory that must not exist yet. // The pair is made from this run's own build output, into a directory that must not exist yet.
expect(deploy).toContain('--output "type=local,dest=${work}/module-package"'); expect(deploy).toContain(`--output "type=local,dest=\${work}/module-package"`);
expect(deploy).toContain('package_output="$work/module-package"');
expect(deploy).toContain('package_output="$module_release_directory"');
expect(deploy).toContain( expect(deploy).toContain(
'package_archive="$(module_package_release "$work/module-package" "$work/package")"', 'package_archive="$(module_package_release "$package_output" "$work/package")"',
); );
expect(deploy).toMatch(/^\s*stage package$/mu); expect(deploy).toMatch(/^\s*stage package$/mu);
// BuildKit's output directory is 0700 and owned by whoever built it: it is never mounted. // BuildKit's output directory is 0700 and owned by whoever built it: it is never mounted.
@@ -187,11 +239,36 @@ describe("CI scripts on a shared Docker daemon", () => {
.split("\n") .split("\n")
.find((line) => line.includes("core/dist/cli/verify-module-package.js")); .find((line) => line.includes("core/dist/cli/verify-module-package.js"));
expect(verifier).toContain('-v "$daemon_work/package:/package:ro"'); expect(verifier).toContain('-v "$daemon_work/package:/package:ro"');
expect(verifier).toContain('"/package/${package_archive}"'); expect(verifier).toContain(`"/package/\${package_archive}"`);
expect(verifier).toContain('--sha256-file "/package/${package_archive}.sha256"'); expect(verifier).toContain(`--sha256-file "/package/\${package_archive}.sha256"`);
expect(verifier).not.toMatch(/--sha256\s/u); expect(verifier).not.toMatch(/--sha256\s/u);
}); });
it("accepts one exact external module release without putting its source in the platform image", async () => {
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).toContain(`module_release_directory="\${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"`);
expect(deploy).toContain(`module_id="\${EKKLESIA_SMOKE_MODULE_ID:-example}"`);
expect(deploy).toContain('package_output="$module_release_directory"');
expect(deploy).toContain('-e SMOKE_MODULE_ID="$module_id"');
expect(deploy).toContain(`--extract-to "/staging/\${module_id}"`);
expect(deploy).toContain(`installed="/modules/\${MODULE_ID}"`);
});
it("proves installed module state across restart, container recreation, and lifecycle changes", async () => {
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).toContain(`"\${compose[@]}" rm --force api`);
expect(deploy).toContain("container recreation made an applied module migration pending again");
expect(deploy).toContain('core/dist/cli/set-module-enabled.js --disable --module "$module_id"');
expect(deploy).toContain('core/dist/cli/set-module-enabled.js --enable --module "$module_id"');
expect(deploy).toContain("the restored display runtime did not return with its data");
});
it("gives page-side display requests enough time on a constrained shared runner", async () => {
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).toContain('-e SMOKE_CALL_TIMEOUT_MS="');
expect(deploy).toContain("EKKLESIA_SMOKE_CALL_TIMEOUT_MS:-30000");
});
it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => { it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => {
// ADR 0048: the split topology installs its test module into module storage at run time. // ADR 0048: the split topology installs its test module into module storage at run time.
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " "); const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
+65 -1
View File
@@ -138,7 +138,7 @@ describe("reference deployment compose files", () => {
]), ]),
); );
expect(services["object-storage"]?.command).toEqual( expect(services["object-storage"]?.command).toEqual(
expect.arrayContaining(["-s3.port.https=8443"]), expect.arrayContaining(["-master.volumeSizeLimitMB=32", "-s3.port.https=8443"]),
); );
const hba = (await text("postgres/pg_hba.conf")) const hba = (await text("postgres/pg_hba.conf"))
@@ -189,6 +189,12 @@ describe("reference deployment compose files", () => {
.join("\n"); .join("\n");
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*"); expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
// The Dashboard's other pages (ADR 0057): every top-level name shaped like a module id, and
// nothing else, so the API — which knows the installed modules — decides page or 404.
expect(directives).toContain(
`@dashboardPage path_regexp dashboardPage ${DASHBOARD_PAGE_PATTERN}`,
);
expect(directives).toMatch(/handle @dashboardPage \{\s*reverse_proxy api:3000 \{/u);
expect(directives).toContain("header_up X-Forwarded-For {client_ip}"); expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
expect(directives).toContain("header_up -CF-Connecting-IP"); expect(directives).toContain("header_up -CF-Connecting-IP");
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32"); expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
@@ -200,6 +206,64 @@ describe("reference deployment compose files", () => {
}); });
}); });
/** The proxies' Dashboard-page matcher: a module id's grammar (`@ekklesia/contracts`) as the first
* segment, then anything. Checked against real paths below so the regular expression itself is
* tested, not just its spelling. */
const DASHBOARD_PAGE_PATTERN = "^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$";
describe("the proxies' Dashboard-page matcher", () => {
const matcher = new RegExp(DASHBOARD_PAGE_PATTERN, "u");
it("forwards every Dashboard page and module-shaped name", () => {
for (const path of [
"/settings",
"/settings/displays/add",
"/account",
"/signage",
"/signage/",
"/signage/timelines",
"/sunday-production",
"/definitely-not-a-real-module",
]) {
expect(matcher.test(path), path).toBe(true);
}
});
it("forwards nothing that could not be a module id", () => {
for (const path of [
"/",
"/favicon.ico",
"/robots.txt",
"/.well-known/x",
"/Signage",
"/-x",
"/1x",
"//signage",
"/a--b",
]) {
expect(matcher.test(path), path).toBe(false);
}
});
});
describe("split-origin reference proxy", () => {
it("serves the Dashboard's page, its deep links, and its script on the Dashboard hostname, and no API", async () => {
const caddyfile = await text("Caddyfile.split.example");
const directives = caddyfile
.split("\n")
.filter((line) => !line.trim().startsWith("#"))
.join("\n");
expect(directives).toContain("@dashboard path / /assets/ekklesia-dashboard/*");
expect(directives).toContain(`path_regexp dashboardPage ${DASHBOARD_PAGE_PATTERN}`);
// The API's own namespaces stay refused on the Dashboard hostname even though they are shaped
// like a module id.
expect(directives).toContain("not path /api /api/* /display /display/* /assets /assets/*");
// The API hostname still serves no Dashboard page at all.
expect(directives).toContain("@api path /api/* /display/* /assets/ekklesia-display/*");
});
});
describe("reference deployment env examples", () => { describe("reference deployment env examples", () => {
it("contain no secret values", async () => { it("contain no secret values", async () => {
const secretKeys = new Map([ const secretKeys = new Map([
+8 -8
View File
@@ -13,18 +13,18 @@
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json" "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json"
}, },
"dependencies": { "dependencies": {
"@ekklesia/design-tokens": "^0.2.1", "@ekklesia/design-tokens": "^0.2.5",
"@ekklesia/module-database": "^0.2.1", "@ekklesia/module-database": "^0.2.5",
"@ekklesia/module-sdk": "^0.2.1", "@ekklesia/module-sdk": "^0.2.5",
"@ekklesia/module-ui-sdk": "^0.2.1", "@ekklesia/module-ui-sdk": "^0.2.5",
"@ekklesia/ui": "^0.2.1", "@ekklesia/ui": "^0.2.5",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@ekklesia/module-pack": "0.2.1", "@ekklesia/module-pack": "0.2.5",
"@ekklesia/module-ui-build": "^0.2.1", "@ekklesia/module-ui-build": "^0.2.5",
"@ekklesia/typescript-config": "^0.2.1", "@ekklesia/typescript-config": "^0.2.5",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/react": "19.2.18", "@types/react": "19.2.18",
"@types/react-dom": "19.2.4", "@types/react-dom": "19.2.4",