Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 cd0287537e Add the account area and account-synced Appearance preferences
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance
chooses System, Light, or Dark and reduced motion (System, Reduce, Allow).

- Core: ekklesia_core_user_preference, one typed, CHECK-constrained,
  nullable column per preference (null = never chosen), migration
  0008_user_preferences (additive), @ekklesia/core/account store, and the
  authenticated-baseline permission core.account.self.manage.
- API: GET /api/v1/account (name, email, preferences) and partial
  PATCH /api/v1/account/preferences, self-only by construction: no user id
  is accepted anywhere; trusted Origin and body limit on the mutation.
- Shell: the account control shows name, email, and initials (never the
  unvetted image URL) and offers Account, Appearance, Sign out; native
  radio groups apply a choice at once through the ADR 0060 controller and
  save it; the server's answer wins and a failed save reverts.
- First paint: a classic, same-origin, render-blocking appearance.js in
  <head> applies a two-word localStorage cache before paint (no inline
  script, CSP unchanged). The account replaces the cache on every load,
  sign-out clears it, and other tabs follow via the storage event.
- Deploy smoke: real-Chromium checks for light/dark, OS emulation,
  overrides, reload persistence, pre-paint, Shadow DOM modules, reduced
  motion, the display staying light, and phone width; the browser
  container gets a 512 MiB /dev/shm so a long session no longer fails
  requests with ERR_INSUFFICIENT_RESOURCES.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:56:50 -05:00
Zayin_Admin 8571e6854c Merge pull request 'feat(shell): Settings hierarchy, Discover, and /settings/<module-id> (Stage 1, ADR 0061)' (#108) from feat/settings-shell into main
CI / verify (push) Successful in 1m14s
CI / infrastructure-smoke (push) Successful in 43s
CI / package-release (push) Successful in 51s
CI / image-build (push) Successful in 34s
CI / migration-smoke (push) Successful in 2m19s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / package-publication (push) Successful in 58s
CI / deploy-smoke (split) (push) Successful in 2m45s
Reviewed-on: #108
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-15 03:43:00 +00:00
ekklesiaandClaude Opus 5.5 89ef4b5ff6 feat(shell): Settings hierarchy, Discover, and /settings/<module-id>
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m29s
CI / image-build (pull_request) Successful in 1m4s
CI / deploy-smoke (same-origin) (pull_request) Successful in 54s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m56s
The Settings foundation (ADR 0061):

- URL model. /settings is a real landing page. The platform sections
  are /settings/general, /branding, /displays, and /system. Module
  lifecycle is Discover: /settings/discover/browse, /installed, and
  /updates. /settings/discover is respelled in place to Browse's one
  canonical address. A module's settings are /settings/<module-id>,
  never nested under Discover. Deeper Settings addresses are
  reserved. "Modules" stays the user-facing word: "Discover modules",
  "Installed modules", "Module updates".
- Collisions. Core's one reserved list gains the Settings section
  names (branding, discover, displays, general, system) as a second
  documented group, so no module id can shadow a section; `modules` is
  an ordinary id. apps/api tests hold it equal to the shell's sections.
- Navigation. One Settings item in the same sidebar expands in place
  into Platform and Modules (and Discover's three views) while in
  Settings, and the phone drawer has the same hierarchy. The rest of
  the navigation stays.
- Permissions. GET /api/v1/dashboard/settings evaluates, through
  Core's evaluator, which destinations the viewer may see:
  - Displays: core.devices.manage
  - Discover: core.modules.lifecycle.manage
  - a module's settings: core.modules.configuration.manage, for each
    module that declares configuration (ADR 0029)
  - General, Branding, System: the platform owner, until each has its
    own permission
  These are presentation hints; every data route keeps its own
  enforcement. With nothing visible, there is no Settings link.
- Pages are honest placeholders with Back to Settings. A disabled
  module's settings say it is turned off; a section or module the
  person cannot see says so without detail; an unknown name is not
  found; a failed load withholds the link and offers Retry.
- The deploy smoke opens Settings by deep link, refreshes a section,
  enters Discover, opens the module's settings, uses the phone
  drawer, and returns to the Dashboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:22:50 -05:00
Zayin_Admin 34692ac05f Merge pull request 'feat(theme): Light/Dark/System, reduced motion, and one radius scale (Stage 1, ADR 0060)' (#107) from feat/theme-foundation into main
CI / verify (push) Successful in 1m10s
CI / infrastructure-smoke (push) Successful in 35s
CI / package-release (push) Successful in 51s
CI / image-build (push) Successful in 34s
CI / migration-smoke (push) Successful in 2m17s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / package-publication (push) Successful in 58s
CI / deploy-smoke (split) (push) Successful in 2m30s
Reviewed-on: #107
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-15 01:22:01 +00:00
ekklesiaandClaude Opus 5.5 d42b64a093 feat(theme): Light/Dark/System, reduced motion, and one radius scale
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m20s
CI / image-build (pull_request) Successful in 1m5s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m38s
The theme foundation DESIGN.md requires (ADR 0060):

- A design token may carry a `dark` value and a `reduced` value. The
  token stylesheet selects them in CSS alone: dark under
  prefers-color-scheme unless the host chose light, or when it chose
  dark; motion durations zero under prefers-reduced-motion unless it
  allowed motion, or when it chose reduce. `color-scheme` travels with
  the tokens. System is live, an explicit choice wins, and every module
  root follows through custom-property inheritance, with no module
  contract change.
- @ekklesia/design-tokens adds a small appearance API: system/light/dark
  and system/reduce/allow, parsers for untrusted stored values,
  resolvers, and a controller that applies the attributes immediately.
  The shell applies it before React renders. That is the hook the
  account preferences work will feed; nothing is persisted. The
  Dashboard page declares `color-scheme: light dark` so a dark system
  never flashes white. Displays stay light.
- A designed dark theme: teal-tinted charcoal, never pure black or
  white, the same teal accent.
- WCAG AA contrast is tested for 41 element pairings in both schemes.
  Two light pairings failed and are fixed visibly: text.muted darkens,
  and form controls get a new border.control (3:1).
  background.placeholder keeps skeletons visible in dark.
- Motion tokens (duration-short, duration-ambient) replace literal
  durations and every per-element reduced-motion rule. radius.scale
  multiplies sm/md/lg, unchanged at its default of 1.
- The sign-in card and module loading/error text move from literal
  colours to tokens. Guards keep raw colours and literal radii out of
  Dashboard product source and @ekklesia/ui.
- Fixes ConfirmationDialog's phrase input, which used a class that
  does not exist (`ek-input`) and so was unstyled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:00:39 -05:00
Zayin_Admin e3e2a901a7 Merge pull request 'feat(ui): shared confirmation, toasts, error, skeleton, and Back primitives (Stage 1, ADR 0059)' (#106) from feat/ui-state-primitives into main
CI / verify (push) Successful in 1m12s
CI / infrastructure-smoke (push) Successful in 41s
CI / package-release (push) Successful in 49s
CI / image-build (push) Successful in 33s
CI / migration-smoke (push) Successful in 2m16s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / package-publication (push) Successful in 55s
CI / deploy-smoke (split) (push) Successful in 2m33s
Reviewed-on: #106
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-15 00:43:53 +00:00
ekklesiaandClaude Opus 5.5 5a7d44781b feat(ui): shared confirmation, toasts, error, skeleton, and Back primitives
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m27s
CI / image-build (pull_request) Successful in 1m6s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m44s
Stage 1's later screens and modules need the interactions DESIGN.md
fixes once for the product (ADR 0059). @ekklesia/ui gains them,
additively:

- ConfirmationDialog: simple, destructive, or typed-phrase
  confirmation, with severity chosen by the caller. Destructive is
  marked by symbol and frame, not colour alone, and starts on Cancel.
  A required phrase must match exactly (forgiving only surrounding
  whitespace and NFC). While busy, Cancel and Escape are disabled.
- ToastProvider / useToasts: a top-center toast system per React tree,
  never a package singleton (ADR 0040). Live regions are mounted
  before any toast arrives; errors are assertive and persist until
  dismissed; routine toasts last at least three seconds and longer for
  longer text. Timers pause on hover and focus; duplicates refresh;
  errors are never displaced; every timer is cleared on unmount.
- ErrorState: recovery actions, plus collapsed technical details and a
  reference, rendered as bounded inert text.
- Skeleton / SkeletonGroup: an aria-busy region labelled once, with
  every animation stopped under prefers-reduced-motion.
- PageHeader `back` (link or button) above the page's one h1.
- Dialog: the focus trap ADR 0039 deferred, plus focus restoration and
  an accessible description. Escape is now handled on the dialog
  itself, so a drawer it was opened from stays open.

The shell validates Back targets (parseReturnTarget, dashboardBack):
only a canonical Dashboard page on this origin is accepted, otherwise
Back goes to the page's parent. It never uses history.back().

Biome's scope-aware suspicious/noAlert is now an error in the root
biome.json. tests/native-dialogs.test.ts proves it catches real
dialogs and not lookalikes, and scans for the forms the rule misses.
It supersedes the shell-only regex test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:24:07 -05:00
Zayin_Admin 651efa8829 Merge pull request 'feat(shell): collapsible sidebar, account menu, and phone drawer (Stage 1, ADR 0058)' (#104) from feat/dashboard-shell-navigation into main
CI / verify (push) Successful in 1m10s
CI / infrastructure-smoke (push) Successful in 34s
CI / package-release (push) Successful in 51s
CI / image-build (push) Successful in 34s
CI / migration-smoke (push) Successful in 2m15s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / package-publication (push) Successful in 59s
CI / deploy-smoke (split) (push) Successful in 2m32s
Reviewed-on: #104
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 23:05:29 +00:00
ekklesiaandClaude Opus 5.5 cf2f847c8c test(core): pin the shell's one use of browser storage, not ban it
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m41s
CI failed in `identity-verification-boundaries.test.ts`: its guard that a
reset token never reaches browser storage was a blanket ban on the word
`localStorage` anywhere in the shell. The sidebar preference (ADR 0058)
legitimately reads it in the entry point.

The guarantee is unchanged: the reset token stays in React state for
the life of one form. The guard is now precise:

- `localStorage` appears only in `main.tsx`, only to hand it to the
  sidebar preference;
- the shell's only storage write is the sidebar's own key and a
  validated mode;
- the files that ever hold the token (`auth-protocol.ts`,
  `ResetPasswordForm.tsx`, `HostApp.tsx`) touch no storage API at all;
- `sessionStorage` and `document.cookie` stay banned everywhere.

A planted `localStorage.setItem("t", token)` in `auth-protocol.ts` fails
it. The runtime reset-token test in `auth-recovery.test.tsx` now runs
with the production localStorage-backed preference and asserts storage
holds nothing but the sidebar key.

It passed locally only because Turbo restored `@ekklesia/core:test` from
cache: that test reads shell source that Core's task hash does not
include.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:55:07 -05:00
ekklesiaandClaude Opus 5.5 af31724cb4 feat(shell): collapsible sidebar, account menu, and phone drawer
CI / verify (pull_request) Failing after 57s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / package-release (pull_request) Skipped
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
The Dashboard's shell now follows DESIGN.md §4 (ADR 0058):

- A collapsible left sidebar replaces the header-and-column layout. It
  is icons and labels when expanded and icons only when collapsed; each
  collapsed item keeps its name, visually hidden, and shows a tooltip
  on hover and on keyboard focus (WCAG 2.2 §1.4.13). The Overview comes
  first, then the server's groups and modules. There is no top bar.
- The expanded/collapsed choice is remembered in this browser's
  localStorage, behind a SidebarPreference interface that a synced
  store can replace later.
- An account menu at the bottom of the sidebar follows the WAI-ARIA menu
  button pattern and offers only Sign out, which now lives there rather
  than in the header.
- On phones (below 48em) the same navigation opens in a modal drawer:
  the page behind it is inert and focus is trapped inside it. Escape,
  its close button, and the backdrop close it and return focus to the
  menu button. Choosing a page closes it and focuses the page. Changing
  layout keeps the route, the desktop mode, and the mounted module.
- Lucide (lucide-react 1.48.0, shell only) is Ekklesia's icon
  vocabulary. Every module shows one generic icon until manifest v9
  lets modules declare their own; there is no per-module icon map.
- The identity slot is a neutral placeholder with no inputs, waiting
  for branding (#101).

The tooltip, menu, and drawer stay shell-local until a second consumer
needs them (ADR 0039). The deploy smoke now signs out through the
account menu, and drives the drawer at phone size in a real browser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:49:56 -05:00
Joey f3b7e19651 Merge pull request 'feat(shell): give every Dashboard page a real URL, with modules at /<module-id> (Stage 1, ADR 0057)' (#103) from feat/dashboard-url-routing into main
CI / verify (push) Successful in 1m8s
CI / infrastructure-smoke (push) Successful in 34s
CI / package-release (push) Successful in 47s
CI / image-build (push) Successful in 32s
CI / migration-smoke (push) Successful in 2m14s
CI / deploy-smoke (same-origin) (push) Successful in 56s
CI / package-publication (push) Successful in 58s
CI / deploy-smoke (split) (push) Successful in 2m30s
Reviewed-on: #103
Reviewed-by: Joey <[email protected]>
2026-10-14 22:07:20 +00:00
ekklesiaandClaude Opus 5.5 ba60f70835 feat(shell): make module pages first-class top-level routes
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 43s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m12s
CI / image-build (pull_request) Successful in 1m0s
CI / deploy-smoke (same-origin) (pull_request) Successful in 56s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
Module pages move from `/modules/<id>` to `/<module-id>` (`/signage`),
beside `/settings` and `/account`, so the implementation concept
"module" never appears in a user-facing address. ADR 0057 is rewritten
to match.

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

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

ADR 0057 makes the address bar the source of truth:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:31:36 -05:00
The_River ae976ca229 Merge pull request 'chore(release): prepare 0.2.4' (#91) from chore/release-0.2.4 into main
CI / verify (push) Successful in 1m13s
CI / infrastructure-smoke (push) Successful in 38s
CI / package-release (push) Successful in 49s
CI / image-build (push) Successful in 30s
CI / migration-smoke (push) Successful in 2m13s
CI / deploy-smoke (same-origin) (push) Successful in 47s
CI / package-publication (push) Successful in 1m4s
CI / deploy-smoke (split) (push) Successful in 2m23s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/91
Reviewed-by: The_River <[email protected]>
2026-10-10 17:09:10 +00:00
ekklesia f9585cc7ec chore(release): prepare 0.2.4
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / package-release (pull_request) Successful in 34s
CI / image-build (pull_request) Successful in 59s
CI / deploy-smoke (same-origin) (pull_request) Successful in 47s
CI / deploy-smoke (split) (pull_request) Successful in 2m21s
CI / package-publication (pull_request) Successful in 49s
2026-10-10 11:54:00 -05:00
The_River 6dae29412f Merge pull request 'test(deploy): exercise external module packages' (#90) from test/external-module-deploy-smoke into main
CI / verify (push) Successful in 1m3s
CI / infrastructure-smoke (push) Successful in 35s
CI / migration-smoke (push) Successful in 1m54s
CI / package-release (push) Successful in 34s
CI / image-build (push) Successful in 26s
CI / deploy-smoke (same-origin) (push) Successful in 40s
CI / deploy-smoke (split) (push) Successful in 2m13s
CI / package-publication (push) Successful in 51s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/90
Reviewed-by: The_River <[email protected]>
2026-10-10 16:32:32 +00:00
ekklesia 40ef0ff802 fix(deploy): bound object storage allocation size
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 50s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 2m19s
CI / package-publication (pull_request) Successful in 52s
2026-10-10 11:07:53 -05:00
ekklesia a0e9948820 fix(storage): recognize errors across module packages
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 29s
CI / migration-smoke (pull_request) Successful in 1m44s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 53s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / deploy-smoke (split) (pull_request) Successful in 2m20s
CI / package-publication (pull_request) Successful in 51s
2026-10-10 10:54:13 -05:00
ekklesia 6e64b3c5f9 test(deploy): allow slower display uploads
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m57s
CI / package-release (pull_request) Successful in 37s
CI / image-build (pull_request) Successful in 54s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 2m21s
CI / package-publication (pull_request) Successful in 52s
2026-10-09 22:37:18 -05:00
ekklesia fa27603133 test(deploy): exercise external module packages
CI / verify (pull_request) Successful in 1m13s
CI / infrastructure-smoke (pull_request) Successful in 40s
CI / migration-smoke (pull_request) Successful in 1m56s
CI / package-release (pull_request) Successful in 37s
CI / image-build (pull_request) Successful in 54s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 2m22s
CI / package-publication (pull_request) Successful in 55s
2026-10-09 22:18:04 -05:00
The_River 9e01679fcb Merge pull request 'chore(release): prepare 0.2.3' (#89) from chore/release-0.2.3 into main
CI / verify (push) Successful in 1m8s
CI / infrastructure-smoke (push) Successful in 28s
CI / migration-smoke (push) Successful in 1m56s
CI / package-release (push) Successful in 37s
CI / image-build (push) Successful in 25s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m22s
CI / package-publication (push) Successful in 50s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/89
Reviewed-by: The_River <[email protected]>
2026-10-10 00:01:54 +00:00
ekklesia 227c92e35d chore(release): prepare 0.2.3
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 1m1s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 55s
2026-10-09 18:38:22 -05:00
The_River 505c9e8c0a Merge pull request 'Fix native package verification on hardened noexec /tmp' (#88) from fix/verifier-exec-scratch into main
CI / verify (push) Successful in 1m9s
CI / infrastructure-smoke (push) Successful in 37s
CI / migration-smoke (push) Successful in 2m1s
CI / package-release (push) Successful in 36s
CI / image-build (push) Successful in 26s
CI / deploy-smoke (same-origin) (push) Successful in 44s
CI / deploy-smoke (split) (push) Successful in 1m27s
CI / package-publication (push) Successful in 54s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/88
Reviewed-by: The_River <[email protected]>
2026-10-09 23:36:11 +00:00
ekklesia 4be046c8d0 fix: support native package verifier scratch
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / package-release (pull_request) Successful in 36s
CI / image-build (pull_request) Successful in 52s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
CI / package-publication (pull_request) Successful in 51s
2026-10-09 18:05:04 -05:00
The_River c5a6cdaf6a Merge pull request 'chore(release): prepare 0.2.2' (#87) from chore/release-0.2.2 into main
CI / verify (push) Successful in 1m7s
CI / infrastructure-smoke (push) Successful in 37s
CI / migration-smoke (push) Successful in 1m54s
CI / package-release (push) Successful in 35s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m22s
CI / package-publication (push) Successful in 1m0s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/87
Reviewed-by: The_River <[email protected]>
2026-10-09 22:07:46 +00:00
ekklesia c4fded6ecf chore(release): prepare 0.2.2
CI / verify (pull_request) Successful in 1m6s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
CI / package-publication (pull_request) Successful in 53s
2026-10-09 16:55:18 -05:00
The_River 9ad2d5b564 Merge pull request 'feat: corresponding-source artifacts for native packages that redistribute copyleft code (ADR 0055)' (#86) from feat/corresponding-source into main
CI / verify (push) Successful in 1m6s
CI / infrastructure-smoke (push) Successful in 37s
CI / migration-smoke (push) Successful in 1m58s
CI / package-release (push) Successful in 38s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 50s
CI / deploy-smoke (split) (push) Successful in 1m22s
CI / package-publication (push) Successful in 51s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/86
Reviewed-by: The_River <[email protected]>
2026-10-09 04:34:50 +00:00
ekklesiaandClaude Opus 5.5 98d93cc12d docs: accept ADR 0052, whose supplement mechanism is fully implemented
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 30s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / package-release (pull_request) Successful in 1m39s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
CI / package-publication (pull_request) Successful in 50s
Every decision in ADR 0052 is implemented and tested in @ekklesia/module-pack:
- package material first, and a supplement only adds to it;
- reviewed, generic supplement data, checked in with the packager;
- an exact name, version, and content-digest match, failing closed on any mismatch;
- the documented, host-independent digest;
- a deterministic, labelled merge;
- no licence override, with a consistency check;
- no network during packaging;
- packageFormat 1.

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

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

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

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

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

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

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

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

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

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

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

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

No version bump, no publication, no tag.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signage is untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 20:16:53 -05:00
588 changed files with 79027 additions and 1041 deletions
+4
View File
@@ -6,3 +6,7 @@
*.gif binary
*.webp binary
*.woff2 binary
# Reviewed license supplements are named by the SHA-256 of their exact bytes (ADR 0052), so Git must
# never rewrite their line endings.
packages/module-pack/license-supplements/** -text
+123
View File
@@ -128,6 +128,129 @@ jobs:
if: always()
run: bash scripts/ci-sweep.sh --job
package-release:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 15
# What the gate proved, for the job that consumes its artifact to check it received exactly that.
outputs:
version: ${{ steps.release.outputs.version }}
revision: ${{ steps.release.outputs.revision }}
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Activate pinned pnpm
run: |
corepack enable
corepack prepare [email protected] --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
# ADR 0053's package release gate: build the public package train from this commit, build it
# again from a second checkout and require byte-identical artifacts, then install exactly those
# tarballs (offline, no registry) into a module outside this repository and prove it builds,
# packages, and verifies. Nothing is published and no credential exists here. A future publish
# job must depend on this job and publish only the artifacts it proved.
- name: Build, reproduce, and consume the public package train
run: pnpm test:package-release --keep package-train
- name: Describe the proven release train
id: release
run: |
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
manifest="$release/ekklesia-package-train.json"
{
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
} >>"$GITHUB_OUTPUT"
cat "$manifest"
# The exact release artifacts, for the jobs that consume them, from a directory whose name does
# not start with a dot: the artifact actions leave hidden files out. Version 3 of the artifact
# actions: version 4 refuses any server that is not github.com, which this Gitea is not, while
# Gitea implements the protocol version 3 uses. A consumer checks the files, not the wrapper.
- name: Hand the release train on
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: package-train
path: package-train/
if-no-files-found: error
retention-days: 7
# The publication tool, given exactly the artifact the package-release job proved, against a
# disposable Gitea 1.27.2 (scripts/test-package-publication.sh). Never the real registry, and no
# credential: the disposable registry's accounts are created and discarded inside the job.
package-publication:
needs: package-release
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: package-publication
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Activate pinned pnpm
run: |
corepack enable
corepack prepare [email protected] --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Receive the release train the gate proved
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: package-train
path: package-train/
- name: Check it is exactly that train
env:
EXPECTED_MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
run: |
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
actual="$(sha256sum "$release/ekklesia-package-train.json" | cut -d' ' -f1)"
if [[ -z "$EXPECTED_MANIFEST_SHA256" || "$actual" != "$EXPECTED_MANIFEST_SHA256" ]]; then
echo "The received release manifest is not the one the package-release job proved." >&2
exit 1
fi
(cd "$release" && sha256sum --check --strict SHA256SUMS)
- name: Publish it to a disposable Gitea, and prove the round trip and recovery
run: bash scripts/test-package-publication.sh --release "$(find package-train -mindepth 1 -maxdepth 1 -type d)"
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
image-build:
needs: verify
runs-on: ubuntu-latest
+133
View File
@@ -0,0 +1,133 @@
name: Publish packages
# Publishes one public @ekklesia/* package train (ADR 0053, docs/development/releases.md) to the
# npm registry: exactly the artifacts this run's package-release gate proves, and nothing else.
#
# Only ever started by hand, on `main`, by someone with write access to this repository: no pull
# request or push can start it. It needs the version-set commit of a release (`version` must equal
# the platform version there), and the repository secret EKKLESIA_PACKAGES_PUBLISH_TOKEN, a token of
# the dedicated publisher account with only the `write:package` scope. Packages are publicly
# readable (ADR 0054); publishing is what this protects.
on:
workflow_dispatch:
inputs:
version:
description: The package train version to publish, equal to the platform version on main.
required: true
type: string
permissions:
contents: read
# Never cancel a publication part way, and never run two at once. A rerun resumes safely.
concurrency:
group: publish-packages
cancel-in-progress: false
env:
# The only registry-specific setting: where the train is published.
EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
jobs:
package-release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
version: ${{ steps.release.outputs.version }}
revision: ${{ steps.release.outputs.revision }}
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Activate pinned pnpm
run: |
corepack enable
corepack prepare [email protected] --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
# The same credential-free gate as CI: build, reproduce, and consume the train.
- name: Build, reproduce, and consume the public package train
run: pnpm test:package-release --keep package-train
- name: Describe the proven release train
id: release
run: |
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
manifest="$release/ekklesia-package-train.json"
{
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
} >>"$GITHUB_OUTPUT"
# Artifact actions version 3: version 4 refuses any server that is not github.com. The directory
# name must not start with a dot: the artifact actions leave hidden files out.
- name: Hand the release train on
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: package-train
path: package-train/
if-no-files-found: error
retention-days: 90
publish:
needs: package-release
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# Only for the publication tool itself: nothing is built or installed in this job.
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Receive the release train the gate proved
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: package-train
path: package-train/
# The tool validates the whole train against its manifest, that manifest against the gate's,
# its version against the request, and its revision against this commit, before it sends
# anything; then publishes, verifies every package byte for byte, and moves `latest` last.
- name: Publish the exact release train
env:
EKKLESIA_PACKAGES_PUBLISH_TOKEN: ${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}
TRAIN_VERSION: ${{ inputs.version }}
TRAIN_REVISION: ${{ github.sha }}
MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
run: |
node scripts/publish-package-train.ts \
--release "$(find package-train -mindepth 1 -maxdepth 1 -type d)" \
--registry "$EKKLESIA_PACKAGES_REGISTRY" \
--version "$TRAIN_VERSION" \
--revision "$TRAIN_REVISION" \
--manifest-sha256 "$MANIFEST_SHA256" \
--record publication-record.json \
--require-public-read
- name: Keep the publication record
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: package-train-publication-record
path: publication-record.json
if-no-files-found: error
retention-days: 90
+12
View File
@@ -1,4 +1,8 @@
node_modules/
# pnpm's content-addressed package store. pnpm keeps it beside node_modules on the same file system,
# so when the checkout is its own mount (a CI runner's workspace volume, a bind-mounted container),
# `pnpm install` creates it here, at the checkout's root. A package cache, never source.
.pnpm-store/
.turbo/
coverage/
dist/
@@ -16,3 +20,11 @@ generated/*
core/.identity-schema-reference.ts
core/.identity-schema-check-*
# The packager tests copy fixture modules here (packages/module-pack/tests/support/fixture-module.ts).
packages/module-pack/.fixtures/
# `pnpm packages:release` writes package release trains here (scripts/package-release.ts).
.package-release/
# CI keeps the proven release here to hand it on as a workflow artifact (scripts/test-package-release.sh --keep).
/package-train/
+71 -15
View File
@@ -6,27 +6,60 @@ contents, because restated details drift.
## What this repository is
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
monorepo:
Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built
as a strictly layered pnpm/TypeScript monorepo:
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
feature module, a surface, or an app.
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
packages. They own their own data and migrations, declare capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
primary and full-viewport display artifacts, authored in React against the same role-neutral
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
imports a feature module, a surface, or an app.
- **Modules** are independently versioned product packages. First-party modules live in **their own
repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
(ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
Module Database ABI v1 (ADR 0050).
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
presentation concern a module does not. They consume public API/client contracts only, never Core
or module internals directly.
presentation concern a module does not. The shell **hosts** module UI through the generic loader;
it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only.
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
documented. Read `docs/architecture/repository-boundaries.md` and
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
## Product direction lives beside this repository
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into
this repository to make them available. They are authoritative for product direction: read the
relevant stages and sections before designing a change. If this repository's documentation disagrees
with them, flag the conflict, correct the repository-local documentation where appropriate, and never
silently follow the stale text. Architectural decisions still change only through ADRs (below).
Rules from those documents that every change here must respect:
- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and
devices, accounts and identity, roles and authorization, branding, mail, push, storage providers,
and module installation/lifecycle — belong to the platform, and modules consume them.
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from
installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may.
Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy.
- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light,
dark, and system modes. Normal UI colours come only from the shared design tokens
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
in product UI. Destructive actions use in-product confirmation proportional to their impact.
- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard
sidebar's *Display devices* panel) and Signage's own display roster are a transitional
implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not
build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell
redesign early, and keep existing paired displays working across any change.
## ADRs are authoritative
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
@@ -43,6 +76,7 @@ At minimum, read the current versions of:
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
- `README.md` (current platform status — what is and is not implemented)
- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above)
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
- `CONTRIBUTING.md`
@@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
## Working conventions
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
change branch-protection settings.
- The canonical repositories are on the church Gitea under the `Zayin` organization:
`ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
and branch from `origin/main`, never from whatever is checked out.
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
merge your own PR, and never change branch-protection settings.
- Never create or push a release tag. Release tags belong to the human maintainer.
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
platform logic into a module to avoid making the correct Core change.
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
burying them in an implementation.
- Add or update tests when you change a contract or architectural rule. New behavior needs new
@@ -66,6 +108,13 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
- Never run a database migration automatically (on import, on startup, in a test that isn't
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
an explicit, operator-invoked action.
- Report what you find. A defect, flaky test, documentation/ADR conflict, or CI problem outside the
task at hand gets an issue in the repository it belongs to, as soon as you find it. Search for an
existing issue first; something fixed in the same PR goes in that PR's description instead. Be
descriptive: what you saw, the evidence (run and job links, commit, file and line), how to
reproduce it, the impact, and options, and never a secret. Label it `found-by-claude` (an agent
that cannot apply labels ends the body with a line saying so), leave it unassigned, and list what
you filed when you report back.
## Canonical verification commands
@@ -78,6 +127,13 @@ ever disagree:
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal
resolution, sign-out). Requires Docker. Run this for any change touching either area.
- `pnpm test:package-release` — builds the public `@ekklesia/*` package train, proves it
reproducible, and proves a standalone module consumes exactly those tarballs. Publishes nothing.
Run it for any change to the public packages or their release tooling.
- `pnpm test:package-publication` — publishes a release train to a disposable Gitea (Docker) and
proves the publication tool. Never the real registry. The real publication is the manual
`Publish packages` workflow only; never run `scripts/publish-package-train.ts` against the real
registry without the user's explicit intent.
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
verify` or the integration suite.
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
+85 -7
View File
@@ -48,12 +48,34 @@ migration history is retained and reported, and blocks no other migration. A mod
as one **Ekklesia Module Package v1**
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
bundled, and checked by Core's verifier before it is installed. Only the portable `any` target exists
so far. Every package records the module's validated SPDX expression and carries its explicitly
bundled, and checked by Core's verifier before it is installed. Two targets exist:
- the portable `any` target, for pure-JavaScript modules;
- `linux-x64-glibc`, for modules with a native dependency such as `sharp`. Its native runtime
packages are carried as real files under the package's `node_modules`, and the verifier checks the
host before anything is loaded.
Every package records the module's validated SPDX expression and carries its explicitly
supplied `LICENSE` bytes; dependency material remains separate in `THIRD_PARTY_NOTICES`. ABI-v1
packages may bundle a module-owned Drizzle version without coupling it to Core;
legacy-v8 packages retain their transitional exact-version check. Purge, native-target packages,
signing, and any installer or marketplace do not exist yet.
legacy-v8 packages retain their transitional exact-version check. Where a redistributed dependency
omits licence material, the packager may add reviewed licence supplements
([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). A native package that
redistributes LGPL or MPL code is distributed publicly only with its corresponding-source artifact,
which `ekklesia-corresponding-source` builds and gates
([ADR 0055](docs/architecture/decisions/0055-corresponding-source-artifacts.md)). Purge, other
operating-system or CPU targets, signing, and any installer or marketplace do not exist yet.
A module can be built in its own repository from Ekklesia's public module-development packages
([ADR 0053](docs/architecture/decisions/0053-standalone-module-development-packages.md)):
a curated `@ekklesia/module-sdk`, `@ekklesia/module-database`, the UI packages, and the build and
packaging tools. They are released as one coordinated train of tarballs with a release manifest,
and CI proves on every run that two builds of a commit are byte-identical and that a module outside
the repository installs, builds, packages, and verifies from those artifacts alone. A manual
workflow, the only way to publish, publishes exactly those artifacts to the npm registry and
verifies every package byte for byte. Anyone may read and install the packages before launch
([ADR 0054](docs/architecture/decisions/0054-public-package-reads-before-launch.md)). The first train
published will be the next platform release
([Releases and the public package train](docs/development/releases.md)).
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
@@ -800,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
public Website remain separate future designs.
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing
(since added; see the Stage 1 paragraph below),
and no module-management, settings, or storage UI — module enablement and configuration are an API and
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
@@ -883,6 +906,59 @@ the module's responsibility and do not exist yet.**
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
Stage 1 begins with **real Dashboard URLs**
([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The
Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own
address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark,
and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages.
The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout
each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves
the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and
discovery refuses a module that uses one. An installed module that is disabled shows a host-owned
*This page isn't available* rather than loading anything. A module's own
sub-pages, Settings and Account pages, and returning to the workflow a Settings link came from are
later Stage 1 work.
The Dashboard's **shell** follows DESIGN.md §4
([ADR 0058](docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)): a
collapsible left sidebar — icons and labels expanded, icons with accessible tooltips collapsed, the
choice remembered in this browser — with the Overview first, the server's groups and modules below it,
and an account menu anchored at the bottom (Sign out, for now). There is no top bar. On a phone the
same navigation opens in a modal drawer with focus trapped inside it, Escape and the backdrop closing
it, and focus returned to the menu button. Lucide is Ekklesia's icon vocabulary; until modules can
declare their own icon (manifest v9), every module shows one generic icon. Church branding in the
sidebar's identity slot is still to come.
`@ekklesia/ui` now carries the interactions every later screen shares
([ADR 0059](docs/architecture/decisions/0059-shared-interaction-primitives.md)): a product-owned
`ConfirmationDialog` (simple, destructive, or typed-phrase, chosen by the caller), a per-tree
top-center toast system, `ErrorState` with collapsed technical detail and remedies, skeleton loading,
and `PageHeader` with a Back action that returns only to a validated Dashboard page. Every dialog
traps and restores focus, and `pnpm lint` refuses `window.alert`, `confirm`, and `prompt`
repository-wide.
The Dashboard follows the operating system's **Light or Dark** appearance and **reduced-motion**
setting, live and without a reload, and so does every module's UI, through the same design tokens
([ADR 0060](docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md)). The dark theme is
designed rather than inverted, every text and focus pairing is contrast-tested to WCAG AA in both
schemes, and corners scale from one `radius.scale` token. Displays stay light.
**Settings** has its own place in the sidebar
([ADR 0061](docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)): a
landing page at `/settings`; the platform's General, Branding, Displays, Discover, and System; and each
module's settings at `/settings/<module-id>`. Discover is where modules are found, installed, managed,
and updated (`/settings/discover/browse`, `/installed`, `/updates`); configuring a module is its own
Settings page. A person sees only what the server says they may administer, and no Settings link at all
when that is nothing. The pages are placeholders until each section is built.
**Your account** is at the bottom of the sidebar, showing your name, email, and initials
([ADR 0062](docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
`/account` shows who you are signed in as. On `/account/appearance` you choose **System, Light, or
Dark** and **reduced motion** (System, Reduce, or Allow). A choice takes effect at once, is saved to
your account so it follows you to every device, and is applied before the first paint on your next
visit. Managing your own appearance needs no administrative permission. Profile editing and an
avatar image are not part of this.
## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
@@ -938,7 +1014,7 @@ Modules
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
6 permissions registered
7 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
○ Module configuration skipped
@@ -948,7 +1024,7 @@ Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
7 migrations · checksums valid
9 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180
@@ -956,6 +1032,8 @@ Data definitions
core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b
core:0006_device_identity · 1db97b7e3ac5
core:0007_verified_bootstrap_owners · 49013062f3e0
core:0008_user_preferences · fa9d4b0bb9d3
PostgreSQL
✓ Database TLS verified
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/api",
"version": "0.1.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
@@ -28,6 +28,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@ekklesia/design-tokens": "workspace:*",
"@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/typescript-config": "workspace:*",
+157
View File
@@ -0,0 +1,157 @@
import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
import {
type AccountPreferences,
type AccountStore,
AccountStoreError,
parseAccountPreferencesUpdate,
} from "@ekklesia/core/account";
import { CORE_ACCOUNT_SELF_MANAGE } from "@ekklesia/core/authorization";
import { type Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
export const ACCOUNT_PATH = "/api/v1/account";
export const ACCOUNT_PREFERENCES_PATH = `${ACCOUNT_PATH}/preferences`;
/** `GET /api/v1/account`: the signed-in person's own account, as the account area shows it. */
export interface AccountResponse {
readonly name: string;
readonly email: string;
readonly preferences: AccountPreferences;
}
export interface CreateAccountRoutesOptions extends AuthorizationMiddlewareOptions {
readonly store: AccountStore;
readonly trustedOrigins: ReadonlySet<string>;
}
function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. */
function storeFailure(
c: Context,
error: unknown,
fallback: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED",
): Response {
if (error instanceof AccountStoreError) {
if (error.code === "ACCOUNT_NOT_FOUND") {
return sanitizedFailure(c, 404, error.code);
}
if (error.code === "ACCOUNT_PREFERENCES_INVALID") {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
return sanitizedFailure(c, 503, error.code);
}
return sanitizedFailure(c, 503, fallback);
}
async function readJsonBody(c: Context): Promise<unknown> {
try {
return (await c.req.json()) as unknown;
} catch {
return undefined;
}
}
/**
* A signed-in person's own account (ADR 0062): who they are signed in as, and their own
* preferences.
*
* ## Self-only by construction
*
* Neither route accepts a user id — not in the path, not in the query, not in the body. The account
* is always the one the **session** names, so there is no request that could read or change anyone
* else's, whatever permission the caller holds. A body that names any key besides a known
* preference is refused whole.
*
* ## Authorization
*
* Both routes require `core.account.self.manage`, whose baseline is `authenticated`: every
* signed-in person may manage their own account, and needs no church-management permission to do
* so. An anonymous caller and a paired device are refused (`403`). Changing a preference is a
* mutation like any other Core mutation: an exact trusted `Origin` and a bounded body are checked
* before authorization work begins.
*
* ## What the browser learns
*
* Name, email, and preferences — exactly what the account area shows, and nothing else. Not the
* account's image URL, verification state, timestamps, or any credential. Responses are per-person
* and never cached.
*/
export function createAccountRoutes(options: CreateAccountRoutesOptions): Hono {
const routes = new Hono();
const requireSelf = requirePermission(options, CORE_ACCOUNT_SELF_MANAGE);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
routes.get(ACCOUNT_PATH, requireSelf, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
const principal = getAuthorizedPrincipal(c);
// Unreachable through the `authenticated` baseline, which only a person satisfies; checked
// again so the account read can never be keyed by anything but a person's own session.
if (!isAuthenticatedPrincipal(principal)) {
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
try {
const account = await options.store.readAccount(principal.userId);
if (!account) {
return sanitizedFailure(c, 404, "ACCOUNT_NOT_FOUND");
}
return c.json(
{
email: account.email,
name: account.name,
preferences: {
appearance: account.preferences.appearance,
motion: account.preferences.motion,
},
} satisfies AccountResponse,
200,
);
} catch (error) {
return storeFailure(c, error, "ACCOUNT_READ_FAILED");
}
});
routes.patch(
ACCOUNT_PREFERENCES_PATH,
requireOrigin,
mutationBodyLimit,
requireSelf,
async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
const principal = getAuthorizedPrincipal(c);
if (!isAuthenticatedPrincipal(principal)) {
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
const update = parseAccountPreferencesUpdate(await readJsonBody(c));
if (!update) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
try {
const preferences = await options.store.updatePreferences(principal.userId, update);
return c.json(
{
appearance: preferences.appearance,
motion: preferences.motion,
} satisfies AccountPreferences,
200,
);
} catch (error) {
return storeFailure(c, error, "ACCOUNT_WRITE_FAILED");
}
},
);
return routes;
}
+55 -7
View File
@@ -1,6 +1,5 @@
import {
isAuthenticatedPrincipal,
type ModuleRuntimeServices,
type ModuleSecretSource,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
@@ -9,10 +8,12 @@ import type {
AuthorizationStore,
PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { AccountStore } from "@ekklesia/core/account";
import type { DashboardStore } from "@ekklesia/core/dashboard";
import type { DeviceStore } from "@ekklesia/core/devices";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
import type {
AnyModuleRuntimeServices,
DiscoveredModule,
ModuleConfigurationLookup,
ModuleConfigurationSettings,
@@ -28,10 +29,12 @@ import type { Context, MiddlewareHandler } from "hono";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import { createAccountRoutes } from "./account-routes.js";
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
import { createDashboardRoutes } from "./dashboard-routes.js";
import { createDeviceRoutes } from "./device-routes.js";
import { createDashboardSettingsRoutes } from "./dashboard-settings-routes.js";
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js";
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
@@ -66,7 +69,7 @@ export interface CreateApiAppOptions {
/** The same per-module service lookup the caller's started module runtime used for every
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's
* reference-identical service object, never a fresh facade or executor built per request. */
readonly moduleServices: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
readonly moduleServices: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
/** The platform's module UI publication policy — always the one Core builds from the exact
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
@@ -140,6 +143,10 @@ export interface CreateApiAppOptions {
* presentation only — nothing reachable through this store influences discovery, dependency
* ordering, module startup, or any authorization decision. */
readonly dashboard: DashboardStore;
/** Core's persistence for a signed-in person's own account summary and preferences (ADR 0062).
* Required like `dashboard`: every installation has accounts. Reachable only through routes that
* take the account from the session, never from the request. */
readonly account: AccountStore;
/**
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
@@ -363,10 +370,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
// own entirely. See `./health-routes.js`.
app.route("/", createHealthRoutes(options.health));
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
// `./dashboard-host-routes.js`.
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
// The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the
// page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
// `/<module-id>`. They grant no authority and resolve no principal; the page asks
// `/api/v1/principal` itself. See `./dashboard-host-routes.js`.
app.route(
"/",
createDashboardHostRoutes(options.dashboardHostAssets, {
modulePages: options.moduleLifecycle.snapshot.discoveredModules
.filter((module) => module.entrypoints.ui !== undefined)
.map((module) => module.manifest.id),
}),
);
app.route(
"/",
@@ -407,6 +422,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
registry: options.authorization.registry,
runtime,
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
...(options.devices
? {
renewDeviceCredential: (c: Context) =>
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
}
: {}),
});
if (options.devices) {
@@ -441,6 +462,33 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
}),
);
// Which Settings destinations the viewer may see (ADR 0061): server-evaluated presentation hints
// beside the navigation, never authority. See `./dashboard-settings-routes.js`.
app.route(
"/",
createDashboardSettingsRoutes({
evaluator: options.authorization.evaluator,
platformOwners: options.authorization.store,
registry: options.authorization.registry,
runtime,
snapshot: options.moduleLifecycle.snapshot,
}),
);
// The signed-in person's own account and preferences (ADR 0062). Self-only by construction: the
// account is always the session's, and no request value can name another. See
// `./account-routes.js`.
app.route(
"/",
createAccountRoutes({
evaluator: options.authorization.evaluator,
registry: options.authorization.registry,
runtime,
store: options.account,
trustedOrigins: allowedOrigins,
}),
);
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
// rather than inside it. See `./module-lifecycle-routes.js`.
+24 -5
View File
@@ -3,7 +3,9 @@ import { readFile } from "node:fs/promises";
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
/**
* The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
* The three immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044):
* the page, its application script, and the small pre-paint appearance script the page loads in
* `<head>` (ADR 0062).
*
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
* from the exact URLs the surface package exports. Request input is never involved in this read, the
@@ -16,6 +18,8 @@ export interface DashboardHostAssets {
readonly htmlEtag: string;
readonly script: string;
readonly scriptEtag: string;
readonly appearanceScript: string;
readonly appearanceScriptEtag: string;
}
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
@@ -42,11 +46,21 @@ function strongEtag(text: string): string {
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
* valid page that can never render anything.
*/
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
if (html.trim().length === 0 || script.trim().length === 0) {
export function createDashboardHostAssets(
html: string,
script: string,
appearanceScript: string,
): DashboardHostAssets {
if (
html.trim().length === 0 ||
script.trim().length === 0 ||
appearanceScript.trim().length === 0
) {
throw new DashboardHostAssetError();
}
return Object.freeze({
appearanceScript,
appearanceScriptEtag: strongEtag(appearanceScript),
html,
htmlEtag: strongEtag(html),
script,
@@ -92,7 +106,7 @@ export function renderDashboardPage(html: string, apiOrigin: string): string {
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
}
/** Loads only the two surface-declared files, and renders the API origin into the page. A missing,
/** Loads only the three surface-declared files, and renders the API origin into the page. A missing,
* unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers
* `/` with a 404 or a page that cannot find its API. */
export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> {
@@ -101,7 +115,12 @@ export async function loadDashboardHostAssets(apiOrigin: string): Promise<Dashbo
// rejected sibling read is left running after this function has already failed.
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script);
const appearanceScript = await readFile(new URL(dashboardHostAssetUrls.appearance), "utf8");
return createDashboardHostAssets(
renderDashboardPage(html, apiOrigin),
script,
appearanceScript,
);
} catch {
throw new DashboardHostAssetError();
}
+85 -17
View File
@@ -1,16 +1,24 @@
import { moduleIdSchema } from "@ekklesia/contracts";
import { isReservedModuleId } from "@ekklesia/core/modules";
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
import { Hono } from "hono";
import { etagMatches } from "./conditional-request.js";
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
/**
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
* `/api/v1/modules/<module-id>`.
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
* serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
* this server and the shell's router read one list), and at each installed module's `/<module-id>`.
* Every one of those first segments is either a reserved platform name or a module id Core accepted,
* and Core refuses a module id that is a reserved name, so no module page can shadow `/api`,
* `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display
* host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still
* lands on `/` with a query (ADR 0038).
*/
export const DASHBOARD_HOST_PAGE_PATH = "/";
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
/** The pre-paint appearance script (ADR 0062), beside the application script. */
export const DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH = "/assets/ekklesia-dashboard/appearance.js";
/**
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
@@ -50,8 +58,12 @@ export class DashboardHostConfigurationError extends Error {
* a composition can never pair bytes with a validator computed from different bytes. */
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
try {
const snapshot = createDashboardHostAssets(input.html, input.script);
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
const snapshot = createDashboardHostAssets(input.html, input.script, input.appearanceScript);
if (
snapshot.htmlEtag !== input.htmlEtag ||
snapshot.scriptEtag !== input.scriptEtag ||
snapshot.appearanceScriptEtag !== input.appearanceScriptEtag
) {
throw new DashboardHostConfigurationError();
}
return snapshot;
@@ -85,29 +97,73 @@ function fixedAsset(
});
}
export interface DashboardHostRouteOptions {
/**
* The id of every module this process discovered that declares a Dashboard UI — enabled or not,
* since a disabled module's bookmark should open the Dashboard and say the module is unavailable
* rather than fail as an unknown address. Fixed at startup, like the rest of the module set.
*/
readonly modulePages: readonly string[];
}
/** Each module page must be a real module id that names no platform namespace, exactly once.
* Discovery already guarantees this; the routes refuse to compose rather than trust it. */
function validatedModulePages(modulePages: readonly string[]): readonly string[] {
const seen = new Set<string>();
for (const moduleId of modulePages) {
if (
!moduleIdSchema.safeParse(moduleId).success ||
isReservedModuleId(moduleId) ||
DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) ||
seen.has(moduleId)
) {
throw new DashboardHostConfigurationError();
}
seen.add(moduleId);
}
return [...seen];
}
/**
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
* default same-origin shape.
*
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
* the sign-in form until `/api/v1/principal` says otherwise.
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
* from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
* platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
* module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
* server change later) — always with the **same** bytes and the same validator, because the path
* selects a page inside the shell rather than a file here; that is what lets a refreshed or
* bookmarked deep link stay where it was.
*
* A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not
* installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does
* `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall through to the
* application's ordinary sanitized `404`. Like the display host's code, these bytes grant no
* authority: the page renders the sign-in form until `/api/v1/principal` says otherwise.
*/
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
export function createDashboardHostRoutes(
assets: DashboardHostAssets,
options: DashboardHostRouteOptions,
): Hono {
const snapshot = snapshotAssets(assets);
const modulePages = validatedModulePages(options.modulePages);
const routes = new Hono();
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
const page = (ifNoneMatch: string | undefined): Response =>
fixedAsset(
c.req.header("if-none-match"),
ifNoneMatch,
snapshot.html,
snapshot.htmlEtag,
"text/html; charset=utf-8",
DASHBOARD_PAGE_SECURITY_HEADERS,
),
);
);
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
routes.get(`/${name}`, (c) => page(c.req.header("if-none-match")));
routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match")));
}
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
fixedAsset(
@@ -119,5 +175,17 @@ export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
),
);
// Same-origin, revalidated like the application script, and carrying nothing but code: the
// person's cached choice lives in their browser and is read by this script, never served by it.
routes.get(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, (c) =>
fixedAsset(
c.req.header("if-none-match"),
snapshot.appearanceScript,
snapshot.appearanceScriptEtag,
"text/javascript; charset=utf-8",
{},
),
);
return routes;
}
+133
View File
@@ -0,0 +1,133 @@
import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts";
import {
type AuthorizationPlatformOwnerPort,
CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_UI_READ,
} from "@ekklesia/core/authorization";
import type { ModuleLifecycleSnapshot } from "@ekklesia/core/modules";
import { SETTINGS_SECTIONS, type SettingsSection } from "@ekklesia/shell/dashboard-routes";
import { Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
} from "./authorization-middleware.js";
export const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings";
/** One module's settings destination: its id (the `/settings/<module-id>` segment), its display
* name, and whether it is running. A disabled or blocked module is listed so its address can say it
* is unavailable, and flagged so the Dashboard never offers it in navigation. */
export interface DashboardSettingsModuleView {
readonly id: string;
readonly name: string;
readonly available: boolean;
}
export interface DashboardSettingsResponse {
/** The platform sections this viewer may see, in navigation order. */
readonly sections: readonly SettingsSection[];
/** Modules whose settings this viewer may see, by name. */
readonly modules: readonly DashboardSettingsModuleView[];
}
/**
* Who may see each platform section (ADR 0061).
*
* A section whose administration already has a Core permission uses it, so it is delegable today.
* General, Branding, and System have no permission yet — nothing in them exists to authorize — so
* they are shown only to the **platform owner**, the one principal who already holds every
* permission; each gains its own delegable permission in the work that builds it.
*/
const SECTION_ACCESS: Readonly<Record<SettingsSection, PermissionId | "platform-owner">> =
Object.freeze({
branding: "platform-owner",
// Finding, installing, turning on and off, and updating modules: deciding which modules run.
discover: CORE_MODULES_LIFECYCLE_MANAGE,
displays: CORE_DEVICES_MANAGE,
general: "platform-owner",
system: "platform-owner",
});
export interface CreateDashboardSettingsRoutesOptions extends AuthorizationMiddlewareOptions {
/** The boot snapshot: every module this process discovered, and which it started. A module's
* settings exist when its manifest declares configuration (ADR 0029). */
readonly snapshot: ModuleLifecycleSnapshot;
readonly platformOwners: AuthorizationPlatformOwnerPort;
}
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
/**
* The Dashboard's Settings navigation, evaluated for the viewer.
*
* Like the module permission hints in the navigation (ADR 0026), these are **presentation hints,
* not authority**: they decide which Settings destinations the Dashboard offers, so nobody is shown
* administration they cannot carry out (DESIGN.md §9.2). Every route behind a section keeps its own
* enforcement, unchanged. Each answer goes through Core's one evaluator — baselines, grants, expiry,
* and the owner bypass behave exactly as on a real request — and an evaluation failure is a `503`,
* never an empty answer that would look like a permissions problem.
*
* Reading requires `core.modules.ui.read`, the same permission the navigation it sits beside
* requires: any signed-in person may ask, and most will be told there is nothing for them.
*/
export function createDashboardSettingsRoutes(options: CreateDashboardSettingsRoutesOptions): Hono {
const routes = new Hono();
const requireRead = requirePermission(options, CORE_MODULES_UI_READ);
const started = new Set(options.snapshot.startedModuleIds);
const modulesWithSettings = options.snapshot.discoveredModules
.filter((module) => module.manifest.configuration.length > 0)
.map((module) => ({ id: module.manifest.id, name: module.manifest.name }))
.sort((left, right) => compareText(left.name, right.name) || compareText(left.id, right.id));
const holds = async (
principal: EkklesiaPrincipal,
permission: PermissionId,
): Promise<boolean> => {
const declaration = options.registry.get(permission);
if (!declaration) {
return false;
}
return (
await options.evaluator.authorize({
baseline: declaration.baseline,
permission,
principal,
})
).allowed;
};
routes.get(DASHBOARD_SETTINGS_PATH, requireRead, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
const principal = getAuthorizedPrincipal(c);
try {
let owner: boolean | undefined;
const isOwner = async (): Promise<boolean> => {
owner ??=
principal.kind === "authenticated" &&
(await options.platformOwners.isPlatformOwner(principal.userId));
return owner;
};
const sections: SettingsSection[] = [];
for (const section of SETTINGS_SECTIONS) {
const access = SECTION_ACCESS[section];
if (access === "platform-owner" ? await isOwner() : await holds(principal, access)) {
sections.push(section);
}
}
const modules = (await holds(principal, CORE_MODULES_CONFIGURATION_MANAGE))
? modulesWithSettings.map((module) => ({ ...module, available: started.has(module.id) }))
: [];
return c.json({ modules, sections } satisfies DashboardSettingsResponse, 200);
} catch {
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
}
});
return routes;
}
+5 -1
View File
@@ -1,4 +1,5 @@
import type { ModuleDatabase, ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
import { type AccountDrizzleDatabase, createAccountDrizzleView } from "@ekklesia/core/account";
import {
type AuthorizationDrizzleDatabase,
createAuthorizationDrizzleView,
@@ -16,10 +17,12 @@ import {
createModuleDatabaseView,
createModuleLifecycleView,
type ModuleConfigurationDrizzleDatabase,
type ModuleDatabase,
type ModuleLifecycleDrizzleDatabase,
} from "@ekklesia/core/modules";
export interface ApiDatabaseHandle {
readonly account: AccountDrizzleDatabase;
readonly authorization: AuthorizationDrizzleDatabase;
readonly devices: DeviceDrizzleDatabase;
readonly dashboard: DashboardDrizzleDatabase;
@@ -73,6 +76,7 @@ export function createApiDatabase(
const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
return Object.freeze({
account: createAccountDrizzleView(pool),
authorization: createAuthorizationDrizzleView(pool),
close,
devices: createDeviceDrizzleView(pool),
+22 -1
View File
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
*/
function writeDeviceCookie(
c: Context,
options: DeviceRoutesOptions,
options: Pick<DeviceRoutesOptions, "secureCookies">,
name: string,
value: string,
maxAgeSeconds: number,
@@ -106,6 +106,27 @@ function writeDeviceCookie(
});
}
/**
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
*
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
*/
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
if (credential === undefined || credential.length === 0) {
return;
}
writeDeviceCookie(
c,
{ secureCookies },
DEVICE_SESSION_COOKIE,
credential,
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
);
}
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
const routes = new HonoApp();
const now = options.now ?? ((): Date => new Date());
+8
View File
@@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
* surface-owned files before binding and supplies their frozen contents here. */
readonly assets?: DisplayHostAssets;
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
* deployment has device support; called only for a request that resolved to a device. */
readonly renewDeviceCredential?: (c: Context) => void;
}
/** A composition error, detected before any display route is registered. Carries no module id,
@@ -199,6 +202,11 @@ function mountDisplay(
if (!viewer) {
return displayFailure(c);
}
// Every screen asks for this on start, on reconnect, and periodically while running, so it
// is where an in-use credential's browser expiry is pushed forward.
if (principal.kind === "device") {
options.renewDeviceCredential?.(c);
}
return c.json(await describeDisplay(options, principal, display, viewer), 200);
} catch {
return displayFailure(c);
+3 -3
View File
@@ -1,15 +1,15 @@
import {
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleDatabase,
type ModuleDatabaseExecutor,
type ModuleId,
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import type {
AnyModuleRuntimeServices,
ModuleConfigurationLookup,
ModuleDatabase,
ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup,
@@ -40,7 +40,7 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
* received for this API runtime — never a fresh one built per request, and never touched for a
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
* after authorization succeeds. */
readonly services: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
readonly services: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
/**
* Per-module frozen configuration snapshots — the exact map the module runtime started from
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
+5 -5
View File
@@ -1,15 +1,12 @@
import type { Socket } from "node:net";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
type AnyModuleRuntimeServices,
EMPTY_MODULE_SECRET_SOURCE,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts";
import {
createAuthorizationEvaluator,
createAuthorizationStore,
} from "@ekklesia/core/authorization";
import { createAccountStore } from "@ekklesia/core/account";
import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices";
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
@@ -21,6 +18,7 @@ import {
} from "@ekklesia/core/identity";
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
import {
type AnyModuleRuntimeServices,
createModuleConfigurationStore,
createModuleLifecycleStore,
createModuleUiPublicationRegistry,
@@ -642,6 +640,8 @@ export async function startApi(
},
// A typed view over the same single pool as identity, authorization, and the module facade.
dashboard: createDashboardStore(databaseHandle.dashboard),
// The same pool again: a person's own account summary and preferences (ADR 0062).
account: createAccountStore(databaseHandle.account),
dashboardHostAssets,
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
+340
View File
@@ -0,0 +1,340 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
createDevicePrincipal,
type EkklesiaPrincipal,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import { ACCOUNT_APPEARANCES, ACCOUNT_MOTION_PREFERENCES } from "@ekklesia/core/account";
import {
type AuthorizationStore,
CORE_ACCOUNT_SELF_MANAGE,
CORE_PERMISSION_REGISTRY,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules";
import { EKKLESIA_APPEARANCES, EKKLESIA_MOTION_PREFERENCES } from "@ekklesia/design-tokens";
import { describe, expect, it, vi } from "vitest";
import { ACCOUNT_PATH, ACCOUNT_PREFERENCES_PATH } from "../src/account-routes.js";
import { createApiApp } from "../src/app.js";
import {
createInMemoryAccountStore,
type InMemoryAccountPerson,
} from "./support/in-memory-account-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
/**
* `GET /api/v1/account` and `PATCH /api/v1/account/preferences` (ADR 0062): a signed-in person's
* own account and preferences, self-only by construction.
*/
const trustedOrigin = "https://dashboard.example.test";
const adaId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
const graceId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222");
const ada = createAuthenticatedPrincipal(adaId);
const people: readonly InMemoryAccountPerson[] = [
{ email: "[email protected]", name: "Ada Lovelace", userId: adaId },
{
email: "[email protected]",
name: "Grace Hopper",
preferences: { appearance: "light", motion: "reduce" },
userId: graceId,
},
];
interface Options {
readonly principal?: EkklesiaPrincipal;
readonly evaluationFails?: boolean;
}
function build(options: Options = {}) {
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async () => false },
platformOwners: {
isPlatformOwner: async () => {
if (options.evaluationFails) {
throw new Error("database down");
}
return false;
},
},
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ada),
};
const account = createInMemoryAccountStore(people);
const app = createApiApp({
account,
authorization: {
evaluator,
registry: composePlatformPermissionRegistry([]),
store: {} as unknown as AuthorizationStore,
},
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleConfiguration: emptyModuleConfiguration(),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor([], { modules: [] }),
store: createInMemoryModuleLifecycleStore(),
},
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleRoutes: Object.freeze([]),
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as LegacyModuleRuntimeServices,
moduleStorage: emptyModuleStorage(),
moduleUi: createModuleUiPublicationRegistry([]),
runtime,
trustedOrigins: [trustedOrigin],
});
return { account, app };
}
function patch(body: unknown, init: { origin?: string | null; raw?: string } = {}): RequestInit {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (init.origin !== null) {
headers.Origin = init.origin ?? trustedOrigin;
}
return { body: init.raw ?? JSON.stringify(body), headers, method: "PATCH" };
}
describe("core.account.self.manage", () => {
it("is an authenticated-baseline Core permission, so managing your own account needs no grant", () => {
expect(CORE_PERMISSION_REGISTRY.get(CORE_ACCOUNT_SELF_MANAGE)?.baseline).toBe("authenticated");
});
it("holds Core's preference values equal to the design tokens' own", () => {
expect([...ACCOUNT_APPEARANCES]).toEqual([...EKKLESIA_APPEARANCES]);
expect([...ACCOUNT_MOTION_PREFERENCES]).toEqual([...EKKLESIA_MOTION_PREFERENCES]);
});
});
describe("GET /api/v1/account", () => {
it("returns the signed-in person's own name, email, and default preferences", async () => {
const { app } = build();
const response = await app.request(ACCOUNT_PATH);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("vary")).toContain("Cookie");
expect(await response.json()).toEqual({
email: "[email protected]",
name: "Ada Lovelace",
preferences: { appearance: "system", motion: "system" },
});
});
it("returns exactly name, email, and preferences — nothing else about the account", async () => {
const { app } = build();
const body = (await (await app.request(ACCOUNT_PATH)).json()) as Record<string, unknown>;
expect(Object.keys(body).sort()).toEqual(["email", "name", "preferences"]);
expect(Object.keys(body.preferences as object).sort()).toEqual(["appearance", "motion"]);
});
it("refuses an anonymous caller", async () => {
const { account, app } = build({ principal: ANONYMOUS_PRINCIPAL });
const response = await app.request(ACCOUNT_PATH);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(account.requestedUserIds).toEqual([]);
});
it("refuses a paired display device, which has no account", async () => {
const { account, app } = build({
principal: createDevicePrincipal("33333333-3333-4333-8333-333333333333"),
});
const response = await app.request(ACCOUNT_PATH);
expect(response.status).toBe(403);
expect(account.requestedUserIds).toEqual([]);
});
it("reads only the session's account, whatever the request names", async () => {
const { account, app } = build();
const response = await app.request(`${ACCOUNT_PATH}?userId=${graceId}&id=${graceId}`, {
headers: { "X-User-Id": graceId },
});
expect(((await response.json()) as { name: string }).name).toBe("Ada Lovelace");
expect(account.requestedUserIds).toEqual([adaId]);
});
it("has no route that addresses another person's account", async () => {
const { account, app } = build();
for (const path of [`${ACCOUNT_PATH}/${graceId}`, `${ACCOUNT_PREFERENCES_PATH}/${graceId}`]) {
expect((await app.request(path)).status).toBe(404);
expect((await app.request(path, patch({ appearance: "dark" }))).status).toBe(404);
}
expect(account.requestedUserIds).toEqual([]);
});
it("is not found when the session's person no longer exists", async () => {
const { app } = build({
principal: createAuthenticatedPrincipal("99999999-9999-4999-8999-999999999999"),
});
const response = await app.request(ACCOUNT_PATH);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "ACCOUNT_NOT_FOUND" });
});
it("reports a storage failure as a sanitized 503", async () => {
const { account, app } = build();
account.failWith("ACCOUNT_READ_FAILED");
const response = await app.request(ACCOUNT_PATH);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "ACCOUNT_READ_FAILED" });
});
it("reports an authorization outage as a 503, never as a denial", async () => {
// The authenticated baseline needs no owner lookup, so an owner-lookup outage cannot deny it.
const { app } = build({ evaluationFails: true });
expect((await app.request(ACCOUNT_PATH)).status).toBe(200);
});
});
describe("PATCH /api/v1/account/preferences", () => {
it.each([
["appearance", "light"],
["appearance", "dark"],
["appearance", "system"],
["motion", "reduce"],
["motion", "allow"],
["motion", "system"],
] as const)("saves %s %s and returns everything in effect", async (name, value) => {
const { app } = build();
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ [name]: value }));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
const body = (await response.json()) as Record<string, string>;
expect(body[name]).toBe(value);
expect(Object.keys(body).sort()).toEqual(["appearance", "motion"]);
const read = (await (await app.request(ACCOUNT_PATH)).json()) as {
preferences: Record<string, string>;
};
expect(read.preferences[name]).toBe(value);
});
it("changes only the preference named", async () => {
const { app } = build();
await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ motion: "reduce" }));
expect(await response.json()).toEqual({ appearance: "dark", motion: "reduce" });
});
it.each([
["an empty object", {}],
["an unknown appearance", { appearance: "sepia" }],
["an unknown motion", { motion: "none" }],
["an unknown key", { appearance: "dark", radius: "large" }],
["another person's id", { appearance: "dark", userId: "22222222-2222-4222-8222-222222222222" }],
["an array", ["dark"]],
["null", null],
])("refuses %s with nothing changed", async (_label, body) => {
const { account, app } = build();
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch(body));
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(account.requestedUserIds).toEqual([]);
});
it("refuses a body that is not JSON", async () => {
const { app } = build();
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch(undefined, { raw: "{" }));
expect(response.status).toBe(400);
});
it("refuses an untrusted or missing Origin before anything else", async () => {
const { account, app } = build();
for (const origin of ["https://elsewhere.example.test", null] as const) {
const response = await app.request(
ACCOUNT_PREFERENCES_PATH,
patch({ appearance: "dark" }, { origin }),
);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
}
expect(account.requestedUserIds).toEqual([]);
});
it("refuses an oversized body", async () => {
const { app } = build();
const response = await app.request(
ACCOUNT_PREFERENCES_PATH,
patch(undefined, { raw: JSON.stringify({ appearance: "dark", pad: "x".repeat(20_000) }) }),
);
expect(response.status).toBe(413);
});
it("refuses an anonymous caller and a device", async () => {
for (const principal of [
ANONYMOUS_PRINCIPAL,
createDevicePrincipal("33333333-3333-4333-8333-333333333333"),
]) {
const { account, app } = build({ principal });
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
expect(response.status).toBe(403);
expect(account.requestedUserIds).toEqual([]);
}
});
it("changes only the session's own preferences, never another person's", async () => {
const { account, app } = build();
await app.request(
`${ACCOUNT_PREFERENCES_PATH}?userId=${graceId}`,
patch({ appearance: "dark", motion: "allow" }),
);
expect(account.requestedUserIds).toEqual([adaId]);
expect((await account.readAccount(graceId))?.preferences).toEqual({
appearance: "light",
motion: "reduce",
});
});
it("reports a storage failure as a sanitized 503", async () => {
const { account, app } = build();
account.failWith("ACCOUNT_WRITE_FAILED");
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "ACCOUNT_WRITE_FAILED" });
});
it("answers a preflight from the Dashboard origin with PATCH allowed", async () => {
const { app } = build();
const response = await app.request(ACCOUNT_PREFERENCES_PATH, {
headers: {
"Access-Control-Request-Method": "PATCH",
Origin: trustedOrigin,
},
method: "OPTIONS",
});
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("access-control-allow-methods")).toContain("PATCH");
});
});
+196 -15
View File
@@ -208,6 +208,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
}
}
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
const SHUTDOWN_SETTLE_MS = 3_000;
/**
* The connections left on `databaseName` once a shutdown has had a moment to land.
*
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
*/
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
for (;;) {
const count = await activeConnectionCount(databaseName);
if (count === 0 || Date.now() >= deadline) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
@@ -221,9 +244,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.0",
},
});
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
@@ -237,6 +260,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]);
expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
@@ -339,7 +363,7 @@ async function createGatedRouteModule(
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/server/index.js" },
id,
manifestVersion: 8,
@@ -390,7 +414,7 @@ async function createFixtureModule(
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
permissions: [],
entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides,
@@ -521,7 +545,7 @@ async function createUiModule(
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id,
manifestVersion: 8,
@@ -1349,7 +1373,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
await running.shutdown();
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
await dropDatabase(database.name);
@@ -1498,6 +1522,162 @@ describe.sequential("live API — exported browser auth shell against PostgreSQL
});
});
describe.sequential("live API — a person's own account and preferences (ADR 0062)", () => {
let database: { name: string; url: string };
let running: RunningApi;
let origin: string;
let ada: { cookie: string; userId: string };
let grace: { cookie: string; userId: string };
const password = "correct horse battery staple";
async function account(cookie?: string): Promise<{ status: number; body: unknown }> {
const response = await fetch(`${origin}/api/v1/account`, {
headers: { origin: dashboardOrigin, ...(cookie ? { cookie } : {}) },
});
return { body: await response.json(), status: response.status };
}
async function changePreferences(
cookie: string | undefined,
body: unknown,
options: { readonly origin?: string; readonly query?: string } = {},
): Promise<{ status: number; body: unknown }> {
const response = await fetch(`${origin}/api/v1/account/preferences${options.query ?? ""}`, {
body: JSON.stringify(body),
headers: {
"content-type": "application/json",
origin: options.origin ?? dashboardOrigin,
...(cookie ? { cookie } : {}),
},
method: "PATCH",
});
return { body: await response.json(), status: response.status };
}
async function person(email: string): Promise<{ cookie: string; userId: string }> {
const created = await signUp(origin, email, password);
expect(created.response.status).toBe(200);
const session = await signIn(origin, email, password);
if (!session.cookiePair) {
throw harnessError("The account session cookie was not issued.");
}
return { cookie: session.cookiePair, userId: created.body.user.id };
}
it("starts a freshly migrated API with two ordinary accounts", async () => {
database = await createDisposableDatabase("account_prefs");
await applyCoreMigrations(database.url);
running = await startMigratedApi(database.url);
origin = apiOrigin(running);
ada = await person("[email protected]");
grace = await person("[email protected]");
});
it("shows each person their own name, email, and the defaults, with no grant", async () => {
expect(await account(ada.cookie)).toEqual({
body: {
email: "[email protected]",
name: "Integration Test User",
preferences: { appearance: "system", motion: "system" },
},
status: 200,
});
expect(((await account(grace.cookie)).body as { email: string }).email).toBe(
"[email protected]",
);
});
it("refuses an anonymous caller both routes", async () => {
expect((await account()).status).toBe(403);
expect((await changePreferences(undefined, { appearance: "dark" })).status).toBe(403);
});
it("saves each person's change to their own account only", async () => {
expect(await changePreferences(ada.cookie, { appearance: "dark" })).toEqual({
body: { appearance: "dark", motion: "system" },
status: 200,
});
expect(await changePreferences(ada.cookie, { motion: "reduce" })).toEqual({
body: { appearance: "dark", motion: "reduce" },
status: 200,
});
expect((await account(grace.cookie)).body).toMatchObject({
preferences: { appearance: "system", motion: "system" },
});
});
it("cannot be pointed at another person's account by the query or the body", async () => {
const viaQuery = await changePreferences(
ada.cookie,
{ appearance: "light" },
{ query: `?userId=${grace.userId}` },
);
expect(viaQuery).toEqual({ body: { appearance: "light", motion: "reduce" }, status: 200 });
const viaBody = await changePreferences(ada.cookie, {
appearance: "dark",
userId: grace.userId,
});
expect(viaBody).toEqual({ body: { code: "INVALID_REQUEST" }, status: 400 });
expect((await account(grace.cookie)).body).toMatchObject({
preferences: { appearance: "system", motion: "system" },
});
expect((await account(ada.cookie)).body).toMatchObject({
preferences: { appearance: "light", motion: "reduce" },
});
});
it("refuses an untrusted Origin and an invalid value, changing nothing", async () => {
expect(
await changePreferences(ada.cookie, { appearance: "dark" }, { origin: untrustedOrigin }),
).toEqual({ body: { code: "UNTRUSTED_ORIGIN" }, status: 403 });
expect(await changePreferences(ada.cookie, { appearance: "sepia" })).toEqual({
body: { code: "INVALID_REQUEST" },
status: 400,
});
expect((await account(ada.cookie)).body).toMatchObject({
preferences: { appearance: "light", motion: "reduce" },
});
});
it("follows the person to a new session, as another device would see it", async () => {
const elsewhere = await signIn(origin, "[email protected]", password);
expect((await account(elsewhere.cookiePair as string)).body).toMatchObject({
preferences: { appearance: "light", motion: "reduce" },
});
});
it("stores exactly one typed row, for the one person who chose something", async () => {
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ user_id: string; appearance: string; motion: string }>(
admin,
"select user_id, appearance, motion from ekklesia_core_user_preference",
);
expect(rows).toEqual([{ appearance: "light", motion: "reduce", user_id: ada.userId }]);
} finally {
await safeEnd(admin);
}
});
it("refuses a signed-out session", async () => {
const signedOut = await fetch(`${origin}/api/auth/sign-out`, {
body: "{}",
headers: { "content-type": "application/json", cookie: ada.cookie, origin: dashboardOrigin },
method: "POST",
});
expect(signedOut.status).toBe(200);
expect((await account(ada.cookie)).status).toBe(403);
expect((await changePreferences(ada.cookie, { appearance: "dark" })).status).toBe(403);
});
it("shuts down cleanly", async () => {
await running.shutdown();
await dropDatabase(database.name);
});
});
describe.sequential("live API — startup and migration guarantees", () => {
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
const database = await createDisposableDatabase("unmigrated");
@@ -2230,7 +2410,7 @@ describe.sequential("live API — platform owner and delegated grant management"
it("shuts down cleanly, closing the one shared pool", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
await dropDatabase(database.name);
});
@@ -2592,9 +2772,9 @@ describe.sequential("live API — installed module permission end-to-end", () =>
const report = await diagnoseEkklesia({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.0",
},
runtimeRange: ">=0.0.0",
runtimeVersion: process.versions.node,
@@ -2607,10 +2787,11 @@ describe.sequential("live API — installed module permission end-to-end", () =>
expect(moduleCheck).toMatchObject({ status: "pass" });
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
expect(permissionsCheck).toMatchObject({
// Core's own six (`core.authorization.grants.manage`, `core.modules.ui.read`,
// Core's own seven (`core.authorization.grants.manage`, `core.modules.ui.read`,
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
// `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one.
details: ["7 permissions registered"],
// `core.modules.configuration.manage`, `core.devices.manage`, `core.account.self.manage`)
// plus the fixture module's one.
details: ["8 permissions registered"],
status: "pass",
});
// Doctor and permission composition read only the manifest — the fixture's register/start
@@ -2837,7 +3018,7 @@ describe.sequential("live API — installed module permission end-to-end", () =>
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
// Exactly one stop, matching the exactly-one register/start already asserted above.
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
@@ -3906,9 +4087,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.0",
},
});
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
+28 -4
View File
@@ -1,8 +1,6 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
moduleIdSchema,
parseEkklesiaUserId,
parsePermissionId,
@@ -20,6 +18,8 @@ import { IdentityRuntimeError } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleUiPublicationRegistry,
StagedModuleRealtimeRegistry,
} from "@ekklesia/core/modules";
@@ -30,6 +30,7 @@ import {
createApiApp as createApiAppWithModules,
} from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
@@ -48,9 +49,9 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
};
}
/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
* never real database behavior, so no PostgreSQL is required. */
function fakeModuleServices(): ModuleRuntimeServices {
function fakeModuleServices(): LegacyModuleRuntimeServices {
const database = Object.freeze({
delete: vi.fn(),
execute: vi.fn(),
@@ -111,6 +112,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -130,6 +132,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -366,6 +369,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -391,6 +395,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -418,6 +423,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -447,6 +453,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -476,6 +483,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -502,6 +510,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -530,6 +539,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -555,6 +565,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -582,6 +593,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -616,6 +628,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -651,6 +664,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -691,6 +705,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -713,6 +728,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -737,6 +753,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -760,6 +777,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -781,6 +799,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -809,6 +828,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -840,6 +860,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -874,6 +895,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -899,6 +921,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -938,6 +961,7 @@ describe("split Dashboard and API origins (ADR 0045)", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
moduleRealtime,
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
+34 -8
View File
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({
dashboardHostAssetUrls: Object.freeze({
html: "file:///surface/browser/index.html",
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
appearance: "file:///surface/browser/assets/ekklesia-dashboard/appearance.js",
}),
}));
@@ -28,19 +29,26 @@ function sha256Etag(text: string): string {
describe("Dashboard host asset loading", () => {
beforeEach(() => readFileMock.mockReset());
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
const script = "export const dashboardHost = true;\n";
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
const appearanceScript = "(() => {})();\n";
readFileMock
.mockResolvedValueOnce(html)
.mockResolvedValueOnce(script)
.mockResolvedValueOnce(appearanceScript);
const assets = await loadDashboardHostAssets(API_ORIGIN);
expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/index.html"), "utf8"],
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
[new URL("file:///surface/browser/assets/ekklesia-dashboard/appearance.js"), "utf8"],
]);
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
expect(assets).toEqual({
appearanceScript,
appearanceScriptEtag: sha256Etag(appearanceScript),
html: rendered,
htmlEtag: sha256Etag(rendered),
script,
@@ -83,12 +91,27 @@ describe("Dashboard host asset loading", () => {
expect(readFileMock).toHaveBeenCalledTimes(2);
});
it("rejects an empty page or script as an unavailable shell build", () => {
for (const [html, script] of [
["", "export {};"],
["<html></html>", " \n"],
it("fails when the appearance script is missing even though the page and script were read", async () => {
readFileMock
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
.mockResolvedValueOnce("export {};\n")
.mockRejectedValueOnce(new Error("ENOENT appearance.js"));
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
DashboardHostAssetError,
);
expect(readFileMock).toHaveBeenCalledTimes(3);
});
it("rejects an empty page, script, or appearance script as an unavailable shell build", () => {
for (const [html, script, appearance] of [
["", "export {};", "(() => {})();"],
["<html></html>", " \n", "(() => {})();"],
["<html></html>", "export {};", " "],
] as const) {
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
expect(() => createDashboardHostAssets(html, script, appearance)).toThrow(
DashboardHostAssetError,
);
}
});
@@ -99,7 +122,10 @@ describe("Dashboard host asset loading", () => {
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
]) {
readFileMock.mockReset();
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
readFileMock
.mockResolvedValueOnce(html)
.mockResolvedValueOnce("export {};\n")
.mockResolvedValueOnce("(() => {})();\n");
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
DashboardHostAssetError,
);
+295 -10
View File
@@ -1,9 +1,8 @@
import { createHash } from "node:crypto";
import {
ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRouteHandler,
type ModuleRuntimeServices,
parseModuleManifest,
parsePermissionId,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
@@ -15,7 +14,15 @@ import {
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
EMPTY_MODULE_REALTIME_REGISTRY,
isReservedModuleId,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleLifecycleConfiguration,
RESERVED_MODULE_IDS,
RESERVED_SETTINGS_NAMES,
RESERVED_TOP_LEVEL_NAMES,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
@@ -31,13 +38,27 @@ import {
} from "../src/dashboard-host-assets.js";
import {
DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
DashboardHostConfigurationError,
createDashboardHostRoutes,
} from "../src/dashboard-host-routes.js";
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
import { DASHBOARD_PLATFORM_PAGES, SETTINGS_SECTIONS } 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 { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
@@ -54,6 +75,8 @@ function sha256Etag(text: string): string {
const PAGE_ETAG = sha256Etag(PAGE_HTML);
const SCRIPT_ETAG = sha256Etag(SCRIPT);
const APPEARANCE_SCRIPT = "(() => { /* pre-paint appearance */ })();\n";
const APPEARANCE_SCRIPT_ETAG = sha256Etag(APPEARANCE_SCRIPT);
function fakeRuntime(): EkklesiaIdentityRuntime {
return {
@@ -74,13 +97,44 @@ function fakeStore(): AuthorizationStore {
};
}
function fakeModuleServices(): ModuleRuntimeServices {
function fakeModuleServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id
* and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */
function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: {
server: "./dist/server/index.js",
...(options.ui ? { ui: "./dist/ui/index.js" } : {}),
},
id,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
version: "0.1.0",
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: {
server: `/modules/${id}/dist/server/index.js`,
...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}),
},
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
function buildApp(
options: {
readonly assets?: DashboardHostAssets;
readonly configuration?: ModuleLifecycleConfiguration;
readonly discovered?: readonly DiscoveredModule[];
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
} = {},
) {
@@ -100,11 +154,16 @@ function buildApp(
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: fakeModuleServices(),
moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
dashboardHostAssets:
options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
health: readyHealth(),
runtime,
trustedOrigins: [trustedOrigin],
@@ -193,6 +252,182 @@ 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, APPEARANCE_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_TOP_LEVEL_NAMES].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_TOP_LEVEL_NAMES, page).toContain(page);
}
});
it("include exactly the Settings sections the shell interprets, so /settings/<module-id> never collides (ADR 0061)", () => {
expect([...SETTINGS_SECTIONS].sort()).toEqual([...RESERVED_SETTINGS_NAMES]);
for (const section of SETTINGS_SECTIONS) {
expect(isReservedModuleId(section), section).toBe(true);
}
expect(RESERVED_MODULE_IDS).toEqual(
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(),
);
});
});
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
const { app } = buildApp();
@@ -231,8 +466,45 @@ describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
});
});
describe("GET /assets/ekklesia-dashboard/appearance.js (the pre-paint appearance script)", () => {
it("serves the exact pre-paint script as JavaScript with revalidation and its own strong ETag", async () => {
const { app } = buildApp();
const response = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH);
expect(response.status).toBe(200);
expect(await response.text()).toBe(APPEARANCE_SCRIPT);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
// Code only: nothing about any viewer, and nothing that varies by request.
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("vary")).toBeNull();
});
it("answers a matching If-None-Match with a 304, and does not accept the app script's validator", async () => {
const { app } = buildApp();
const notModified = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, {
headers: { "If-None-Match": APPEARANCE_SCRIPT_ETAG },
});
expect(notModified.status).toBe(304);
expect(notModified.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG);
const other = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, {
headers: { "If-None-Match": SCRIPT_ETAG },
});
expect(other.status).toBe(200);
});
it("lives beside the app script, in the Dashboard's own asset namespace", () => {
expect(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH).toBe("/assets/ekklesia-dashboard/appearance.js");
});
});
describe("Dashboard host boundaries", () => {
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
it("serves nothing but its exact paths, answering every other spelling with the sanitized 404", async () => {
const { app } = buildApp();
for (const path of [
@@ -246,6 +518,10 @@ describe("Dashboard host boundaries", () => {
"/assets/ekklesia-dashboard/index.html",
"/assets/",
"/assets/ekklesia-dashboard/APP.JS",
"/assets/ekklesia-dashboard/appearance.js.map",
"/assets/ekklesia-dashboard/appearance.js/",
"/assets/ekklesia-dashboard/APPEARANCE.JS",
"/appearance.js",
"/dashboard",
"/login",
]) {
@@ -334,12 +610,21 @@ describe("Dashboard host boundaries", () => {
});
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT);
for (const assets of [
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
{ ...consistent, scriptEtag: PAGE_ETAG },
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
{ ...consistent, appearanceScriptEtag: SCRIPT_ETAG },
{ ...consistent, appearanceScript: `${APPEARANCE_SCRIPT}// tampered` },
{
appearanceScript: APPEARANCE_SCRIPT,
appearanceScriptEtag: APPEARANCE_SCRIPT_ETAG,
html: "",
htmlEtag: sha256Etag(""),
script: SCRIPT,
scriptEtag: SCRIPT_ETAG,
},
]) {
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
}
+5 -3
View File
@@ -5,8 +5,6 @@ import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -20,12 +18,15 @@ import {
createModuleUiPublicationRegistry,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
@@ -140,6 +141,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -147,7 +149,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
}) as LegacyModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
@@ -0,0 +1,250 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
parseEkklesiaUserId,
parseModuleManifest,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleLifecycleConfiguration,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import {
DASHBOARD_SETTINGS_PATH,
type DashboardSettingsResponse,
} from "../src/dashboard-settings-routes.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
/**
* `GET /api/v1/dashboard/settings` (ADR 0061): which Settings destinations the viewer may see,
* evaluated by the server through Core's one authorization evaluator. Presentation hints, never
* authority: every Settings data route keeps its own enforcement.
*/
const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444");
const viewer = createAuthenticatedPrincipal(viewerId);
function discovered(
id: string,
options: { readonly name?: string; readonly settings: boolean },
): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
configuration: options.settings
? [
{
default: "Hello",
description: "A greeting.",
key: "greeting",
maxLength: 40,
type: "string",
},
]
: [],
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id,
manifestVersion: 8,
name: options.name ?? `${id} module`,
permissions: [],
version: "0.1.0",
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: {
server: `/modules/${id}/dist/server/index.js`,
ui: `/modules/${id}/dist/ui/index.js`,
},
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
interface Options {
readonly principal?: EkklesiaPrincipal;
readonly owner?: boolean;
readonly grants?: readonly string[];
readonly modules?: readonly DiscoveredModule[];
readonly configuration?: ModuleLifecycleConfiguration;
readonly ownerLookupFails?: boolean;
}
function build(options: Options = {}) {
const modules = options.modules ?? [
discovered("signage", { name: "Signage", settings: true }),
discovered("calendar", { name: "Calendar", settings: true }),
discovered("retired", { name: "Archive", settings: true }),
discovered("plain", { name: "Plain", settings: false }),
];
const isPlatformOwner = vi.fn(async () => {
if (options.ownerLookupFails) {
throw new Error("database down");
}
return options.owner === true;
});
const grants = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async ({ permission }) => grants.has(String(permission)) },
platformOwners: { isPlatformOwner },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? viewer),
};
const store = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn(),
isPlatformOwner,
listGrantsForUser: vi.fn(),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleConfiguration: emptyModuleConfiguration(),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(
modules,
options.configuration ?? { modules: [{ enabled: false, moduleId: "retired" }] },
),
store: createInMemoryModuleLifecycleStore(),
},
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleRoutes: Object.freeze([]),
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as LegacyModuleRuntimeServices,
moduleStorage: emptyModuleStorage(),
moduleUi: createModuleUiPublicationRegistry([]),
runtime,
trustedOrigins: ["https://dashboard.example.test"],
});
return { app, isPlatformOwner };
}
async function settings(options: Options = {}) {
const { app } = build(options);
const response = await app.request(DASHBOARD_SETTINGS_PATH);
const body =
response.status === 200 ? ((await response.json()) as DashboardSettingsResponse) : undefined;
return { body, response };
}
describe("GET /api/v1/dashboard/settings", () => {
it("shows a signed-in person with no administrative permission nothing at all", async () => {
const { body, response } = await settings();
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(body).toEqual({ modules: [], sections: [] });
});
it("refuses an anonymous viewer, like the navigation it sits beside", async () => {
const { response } = await settings({ principal: ANONYMOUS_PRINCIPAL });
expect(response.status).toBe(403);
});
it("shows the platform owner every section, in order, and every module with settings", async () => {
const { body } = await settings({ owner: true });
expect(body).toEqual({
modules: [
{ available: false, id: "retired", name: "Archive" },
{ available: true, id: "calendar", name: "Calendar" },
{ available: true, id: "signage", name: "Signage" },
],
sections: ["general", "branding", "displays", "discover", "system"],
});
});
it("shows Displays exactly to whoever may manage devices", async () => {
const { body } = await settings({ grants: [String(CORE_DEVICES_MANAGE)] });
expect(body).toEqual({ modules: [], sections: ["displays"] });
});
it("shows Discover — module lifecycle — exactly to whoever may manage which modules run", async () => {
const { body } = await settings({ grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)] });
expect(body).toEqual({ modules: [], sections: ["discover"] });
});
it("lists modules' settings exactly to whoever may configure modules, flagging disabled ones", async () => {
const { body } = await settings({ grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)] });
// A module that declares no configuration has no settings page; a disabled module is listed so
// its address can say it is unavailable, but marked so the Dashboard does not navigate to it.
expect(body).toEqual({
modules: [
{ available: false, id: "retired", name: "Archive" },
{ available: true, id: "calendar", name: "Calendar" },
{ available: true, id: "signage", name: "Signage" },
],
sections: [],
});
});
it("keeps General, Branding, and System to the platform owner until each has a permission of its own", async () => {
const { body } = await settings({
grants: [
String(CORE_DEVICES_MANAGE),
String(CORE_MODULES_LIFECYCLE_MANAGE),
String(CORE_MODULES_CONFIGURATION_MANAGE),
],
});
expect(body?.sections).toEqual(["displays", "discover"]);
});
it("is a 503, never an empty answer, when authorization cannot be evaluated", async () => {
const { response } = await settings({ ownerLookupFails: true });
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
});
it("is a hint, not authority: the data behind a section stays refused without its permission", async () => {
const { app } = build({ grants: [] });
for (const path of [
"/api/v1/module-lifecycle",
"/api/v1/module-configuration/signage",
"/api/v1/grants",
]) {
const response = await app.request(path);
expect(response.status, path).toBe(403);
}
});
});
+122 -1
View File
@@ -118,7 +118,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: {
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
server: "./dist/server/index.js",
@@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => {
}
});
/**
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
*/
describe("credential lifetime", () => {
const ttlSeconds = 180 * 24 * 60 * 60;
const descriptor = "/api/v1/module-displays/announcements";
function renewal(response: Response): string | undefined {
return setCookieFor(response, "ekklesia_device_session");
}
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
const running = await platform("renewal");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
);
const response = await device.request(descriptor);
expect(response.status).toBe(200);
// The same credential, re-issued with a full lifetime and the same protections.
const header = renewal(response);
expect(header).toBeDefined();
expect(header).toContain(`ekklesia_device_session=${credential};`);
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
expect(header).toMatch(/HttpOnly/u);
expect(header).toMatch(/SameSite=Strict/u);
expect(header).toMatch(/Path=\//u);
expect(header).not.toMatch(/Domain=/iu);
// Using it moved the server's window forward too.
const [row] = await query<{ recent: boolean }>(
running.url,
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
);
expect(row?.recent).toBe(true);
} finally {
await running.close();
}
});
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
const running = await platform("legacy");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
// A production screen paired 179 days ago that checked in this morning: under the old
// rule it had one day left. It must carry straight on.
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
);
expect((await device.request(descriptor)).status).toBe(200);
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
);
expect((await device.request(descriptor)).status).toBe(200);
} finally {
await running.close();
}
});
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
const running = await platform("abandoned");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
);
const response = await device.request(descriptor);
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
expect(response.status).toBe(403);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
it("never renews a revoked screen", async () => {
const running = await platform("revoked-renewal");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const listed = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string }> };
await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
{},
);
const response = await device.request(descriptor);
expect(response.status).toBe(403);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
it("does not hand a device cookie to a person viewing the display", async () => {
const running = await platform("human-renewal");
try {
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
expect(response.status).toBe(200);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
});
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
const running = await platform("mixed");
const device = new DeviceBrowser(running.origin);
+5 -3
View File
@@ -1,8 +1,6 @@
import {
ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRouteHandler,
type ModuleRuntimeServices,
parsePermissionId,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
@@ -15,6 +13,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
@@ -24,6 +24,7 @@ import {
HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
@@ -56,7 +57,7 @@ function fakeStore(): AuthorizationStore {
};
}
function fakeModuleServices(): ModuleRuntimeServices {
function fakeModuleServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
@@ -91,6 +92,7 @@ function buildApp(
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health,
+5 -5
View File
@@ -20,7 +20,7 @@ vi.mock("@hono/node-server", () => ({ serve: serveMock }));
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
loadDashboardHostAssetsMock.mockImplementation(async () =>
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
actual.createDashboardHostAssets("<!doctype html>", "export {};\n", "(() => {})();\n"),
);
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
});
@@ -108,7 +108,7 @@ async function createFixtureModule(
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
permissions: [],
entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides,
@@ -267,7 +267,7 @@ function fakeDiscoveredModule(
): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" },
id,
manifestVersion: 8,
@@ -1435,9 +1435,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.0",
});
mockPoolAndAuth();
mockSuccessfulServe();
@@ -6,8 +6,6 @@ import {
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleConfigurationDeclarationInput,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -22,10 +20,12 @@ import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
type LegacyModuleRuntimeServices,
type ModuleConfigurationRecord,
type ModuleConfigurationStore,
ModuleConfigurationStoreError,
EMPTY_MODULE_REALTIME_REGISTRY,
type ModuleDatabase,
resolveModuleEligibility,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
@@ -34,6 +34,7 @@ import { createApiApp } from "../src/app.js";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
@@ -190,6 +191,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
registry: composePlatformPermissionRegistry(modules),
store: authorizationStore,
},
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -213,7 +215,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
}) as LegacyModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
runtime,
trustedOrigins: [trustedOrigin],
+30 -5
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 }> {
const name = makeDatabaseName(label);
await createDatabase(name);
@@ -212,9 +235,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.0",
},
});
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
@@ -226,6 +249,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
@@ -241,9 +265,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory: fixtureModulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.0",
},
});
// Core migrations aggregate first, exactly once each, and always before any module migration.
@@ -256,6 +280,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
"db-facade:0000_initial",
]);
expect(aggregate.migrations[0]?.checksum).toBe(
@@ -760,7 +785,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
const eventsAfterShutdown = readRecorder();
+5 -3
View File
@@ -5,8 +5,6 @@ import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -21,6 +19,8 @@ import {
type DiscoveredModule,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -37,6 +37,7 @@ import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
@@ -155,7 +156,7 @@ interface Installation {
readonly modules: readonly DiscoveredModule[];
}
function fakeServices(): ModuleRuntimeServices {
function fakeServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
@@ -199,6 +200,7 @@ async function install(options: InstallOptions): Promise<Installation> {
registry: composePlatformPermissionRegistry(modules),
store: fakeAuthorizationStore(),
},
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
+11 -9
View File
@@ -1,10 +1,8 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRouteHandler,
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
moduleRoutesOverlap,
type PermissionId,
parsePermissionId,
@@ -20,6 +18,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleRuntimeServicesLookup,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
@@ -30,6 +30,7 @@ import {
mergedVaryHeader,
} from "../src/module-http-routes.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
@@ -74,11 +75,11 @@ function moduleRoute(
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
}
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise route mounting,
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
* required. Each `database` method stays a distinct spy so a test can assert none of them were
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
function fakeModuleServices(): ModuleRuntimeServices {
function fakeModuleServices(): LegacyModuleRuntimeServices {
const database = Object.freeze({
delete: vi.fn(),
execute: vi.fn(),
@@ -92,7 +93,7 @@ function fakeModuleServices(): ModuleRuntimeServices {
return Object.freeze({ database });
}
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
function expectDatabaseFakeUntouched(services: LegacyModuleRuntimeServices): void {
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
@@ -108,7 +109,7 @@ function buildModuleApp(options: {
readonly registry: PermissionRegistry;
readonly routes: readonly RegisteredModuleHttpRoute[];
readonly runtime?: EkklesiaIdentityRuntime;
readonly services?: ModuleRuntimeServices | ModuleRuntimeServicesLookup;
readonly services?: LegacyModuleRuntimeServices | ModuleRuntimeServicesLookup;
readonly trustedOrigins?: readonly string[];
}) {
const evaluator = options.evaluator ?? {
@@ -125,6 +126,7 @@ function buildModuleApp(options: {
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -539,7 +541,7 @@ describe("module route authorization", () => {
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(handler).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
});
it("does not invoke the handler when principal resolution fails", async () => {
@@ -668,7 +670,7 @@ describe("module route trusted-origin enforcement", () => {
}
expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
});
it("allows a trusted-origin state-changing request through to the handler", async () => {
@@ -797,7 +799,7 @@ describe("module route CORS", () => {
expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
});
it("never reflects an untrusted origin for a module route", async () => {
@@ -5,8 +5,6 @@ import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -20,6 +18,8 @@ import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleLifecycleStore,
ModuleLifecycleStoreError,
EMPTY_MODULE_REALTIME_REGISTRY,
@@ -32,6 +32,7 @@ import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
@@ -172,6 +173,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
registry: composePlatformPermissionRegistry(modules),
store: authorizationStore,
},
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -189,7 +191,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
}) as LegacyModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
runtime,
trustedOrigins: [trustedOrigin],
+3 -3
View File
@@ -4,9 +4,7 @@ import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRealtimePublisher,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -18,6 +16,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
discoverModules,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleRealtimeRegistry,
type ModuleRealtimeSubscription,
startDiscoveredModuleRuntime,
@@ -226,7 +226,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
return { directory, symbol };
}
function services(): ModuleRuntimeServices {
function services(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
}
+45 -4
View File
@@ -5,8 +5,6 @@ import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -18,6 +16,8 @@ import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModules,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules";
import {
@@ -33,8 +33,10 @@ import {
createModuleUiContext,
type EkklesiaHostConfig,
moduleUiSourceUrl,
saveHostAccountPreferences,
} from "@ekklesia/shell";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
@@ -216,6 +218,8 @@ interface HarnessOptions {
* single module proves very little. */
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
readonly platformOwner?: boolean;
/** People with an account, for the account area's routes (ADR 0062). */
readonly people?: Parameters<typeof createInMemoryAccountStore>[0];
}
interface Harness {
@@ -241,7 +245,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
modulesDirectory,
platformVersion: "0.1.0",
});
const services: ModuleRuntimeServices = Object.freeze({
const services: LegacyModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
});
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
@@ -289,6 +293,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
account: createInMemoryAccountStore(options.people ?? []),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -302,7 +307,10 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
if (url.pathname.startsWith("/api/v1/modules/")) {
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`);
}
return app.request(url.href, init);
// A browser names the Dashboard's origin on every cross-origin request; the shell never sets it.
const headers = new Headers(init?.headers);
headers.set("Origin", "https://dashboard.example.test");
return app.request(url.href, { ...init, headers });
}) as unknown as typeof fetch;
return {
@@ -738,6 +746,39 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}
});
it("loads the signed-in person's own account, and a saved preference is what the next load sees (ADR 0062)", async () => {
const harness = await startHarness({
people: [{ email: "[email protected]", name: "Viewer Person", userId: viewerId }],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
const first = await bootstrapHost(harness.config);
expect(first.account).toEqual({
email: "[email protected]",
name: "Viewer Person",
preferences: { appearance: "system", motion: "system" },
});
expect(await saveHostAccountPreferences(harness.config, { appearance: "dark" })).toEqual({
appearance: "dark",
motion: "system",
});
expect(await saveHostAccountPreferences(harness.config, { motion: "reduce" })).toEqual({
appearance: "dark",
motion: "reduce",
});
// Another device signing in reads the account, not this one's browser.
const next = await bootstrapHost(harness.config);
expect(next.account !== "unavailable" && next.account.preferences).toEqual({
appearance: "dark",
motion: "reduce",
});
} finally {
await harness.stop();
}
});
it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
const harness = await startHarness();
try {
+5 -3
View File
@@ -5,8 +5,6 @@ import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
@@ -21,9 +19,12 @@ import {
type DiscoveredModule,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
@@ -192,7 +193,7 @@ async function install(options: InstallOptions): Promise<Installation> {
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const services: ModuleRuntimeServices = Object.freeze({
const services: LegacyModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
});
const permissionRegistry = composePlatformPermissionRegistry(modules);
@@ -209,6 +210,7 @@ async function install(options: InstallOptions): Promise<Installation> {
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
+6 -6
View File
@@ -1,11 +1,7 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRuntimeServices,
} from "@ekklesia/contracts";
import { ANONYMOUS_PRINCIPAL } from "@ekklesia/contracts";
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
@@ -14,12 +10,15 @@ import {
type DiscoveredModule,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host";
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
import { Window } from "happy-dom";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
@@ -215,7 +214,7 @@ async function install(
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
const database = Object.freeze({}) as unknown as ModuleDatabase;
const services: ModuleRuntimeServices = Object.freeze({ database });
const services: LegacyModuleRuntimeServices = Object.freeze({ database });
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
// Anonymous, and every authorization decision denied: whatever this route does, it does it
@@ -256,6 +255,7 @@ async function install(
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
@@ -9,5 +9,6 @@ export function testDashboardHostAssets(): DashboardHostAssets {
return createDashboardHostAssets(
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
"export const dashboardHost = true;\n",
"(() => {})();\n",
);
}
@@ -0,0 +1,85 @@
import type { EkklesiaUserId } from "@ekklesia/contracts";
import {
type AccountPreferences,
type AccountPreferencesUpdate,
type AccountStore,
AccountStoreError,
type AccountSummary,
DEFAULT_ACCOUNT_PREFERENCES,
parseAccountPreferencesUpdate,
} from "@ekklesia/core/account";
export interface InMemoryAccountPerson {
readonly userId: string;
readonly name: string;
readonly email: string;
readonly preferences?: Partial<AccountPreferences>;
}
export interface InMemoryAccountStore extends AccountStore {
/** Every user id a read or write was asked for, in order, so a route test can prove which account
* the route touched — the session's, and never one named by the request. */
readonly requestedUserIds: readonly string[];
/** Makes the next operations fail the way an unreachable database would. */
failWith(code: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED" | undefined): void;
}
/**
* An in-memory `AccountStore` for tests that exercise **routing and authorization**, not
* persistence. It reproduces the store's contract — strict update parsing, partial updates, an
* unknown person is not found — and nothing a database owns; persistence, the `CHECK`
* constraints, isolation across connections, and the cascade are proven against real PostgreSQL in
* `core/tests/account-store.integration.ts`.
*/
export function createInMemoryAccountStore(
people: readonly InMemoryAccountPerson[] = [],
): InMemoryAccountStore {
const records = new Map<string, { name: string; email: string; preferences: AccountPreferences }>(
people.map((person) => [
person.userId,
{
email: person.email,
name: person.name,
preferences: { ...DEFAULT_ACCOUNT_PREFERENCES, ...person.preferences },
},
]),
);
const requestedUserIds: string[] = [];
let failure: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED" | undefined;
return {
failWith(code) {
failure = code;
},
async readAccount(userId: EkklesiaUserId): Promise<AccountSummary | undefined> {
requestedUserIds.push(userId);
if (failure) {
throw new AccountStoreError("ACCOUNT_READ_FAILED");
}
const record = records.get(userId);
return record
? { email: record.email, name: record.name, preferences: { ...record.preferences } }
: undefined;
},
requestedUserIds,
async updatePreferences(
userId: EkklesiaUserId,
update: AccountPreferencesUpdate,
): Promise<AccountPreferences> {
requestedUserIds.push(userId);
const parsed = parseAccountPreferencesUpdate(update);
if (!parsed) {
throw new AccountStoreError("ACCOUNT_PREFERENCES_INVALID");
}
if (failure) {
throw new AccountStoreError("ACCOUNT_WRITE_FAILED");
}
const record = records.get(userId);
if (!record) {
throw new AccountStoreError("ACCOUNT_NOT_FOUND");
}
record.preferences = { ...record.preferences, ...parsed };
return { ...record.preferences };
},
};
}
+4 -1
View File
@@ -19,7 +19,10 @@
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
"preset": "recommended",
"suspicious": {
"noAlert": "error"
}
}
},
"javascript": {
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
"./src/modules/lifecycle-schema.ts",
"./src/modules/configuration-schema.ts",
"./src/devices/schema.ts",
"./src/account/schema.ts",
],
out: "./drizzle",
migrations: {
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE "ekklesia_core_user_preference" (
"user_id" text PRIMARY KEY NOT NULL,
"appearance" text,
"motion" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_user_preference_appearance_check" CHECK ("ekklesia_core_user_preference"."appearance" is null or "ekklesia_core_user_preference"."appearance" in ('system', 'light', 'dark')),
CONSTRAINT "ekklesia_core_user_preference_motion_check" CHECK ("ekklesia_core_user_preference"."motion" is null or "ekklesia_core_user_preference"."motion" in ('system', 'reduce', 'allow'))
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_user_preference" ADD CONSTRAINT "ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+6
View File
@@ -49,6 +49,12 @@
"destructive": {
"mode": "none"
}
},
{
"tag": "0008_user_preferences",
"destructive": {
"mode": "none"
}
}
]
}
+7
View File
@@ -57,6 +57,13 @@
"when": 1787543773058,
"tag": "0007_verified_bootstrap_owners",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1792036365485,
"tag": "0008_user_preferences",
"breakpoints": true
}
]
}
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/core",
"version": "0.1.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
@@ -12,6 +12,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./account": {
"types": "./dist/account/index.d.ts",
"import": "./dist/account/index.js"
},
"./modules": {
"types": "./dist/modules/index.d.ts",
"import": "./dist/modules/index.js"
+13
View File
@@ -0,0 +1,13 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import { accountSchema } from "./schema.js";
export type AccountDrizzleDatabase = NodePgDatabase<typeof accountSchema>;
/**
* A typed Drizzle view over an externally owned pool, exactly like `createDeviceDrizzleView`: it
* never constructs, registers an error listener on, or closes a pool.
*/
export function createAccountDrizzleView(pool: Pool): AccountDrizzleDatabase {
return drizzle(pool, { schema: accountSchema });
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./database.js";
export * from "./preferences.js";
export * from "./schema.js";
export * from "./store.js";
+108
View File
@@ -0,0 +1,108 @@
/**
* A person's own preferences (DESIGN.md §9.2, ADR 0062): how the Dashboard looks and behaves *for
* them*, on every device they sign in on.
*
* The model is deliberately a closed, typed record rather than a key/value bag or a JSON document.
* Every preference has a fixed name, a closed set of values, and a default; a value outside the set
* is refused at this boundary and again by the database's `CHECK` constraint. A new preference is a
* new field here and a new nullable column in a migration — never a second table, and never a
* free-form value a client could fill with anything.
*
* Core cannot import the design-token package that renders these, so the value sets are declared
* here and an `apps/api` test holds them equal to `@ekklesia/design-tokens`' own
* (`EKKLESIA_APPEARANCES`, `EKKLESIA_MOTION_PREFERENCES`): one list cannot change without the other.
*/
/** Light, dark, or following the operating system. `system` is the default. */
export const ACCOUNT_APPEARANCES = Object.freeze(["system", "light", "dark"] as const);
export type AccountAppearance = (typeof ACCOUNT_APPEARANCES)[number];
/** Following `prefers-reduced-motion`, always reducing, or always allowing the platform's subtle
* motion. `system` is the default. */
export const ACCOUNT_MOTION_PREFERENCES = Object.freeze(["system", "reduce", "allow"] as const);
export type AccountMotionPreference = (typeof ACCOUNT_MOTION_PREFERENCES)[number];
export interface AccountPreferences {
readonly appearance: AccountAppearance;
readonly motion: AccountMotionPreference;
}
/** What a person who has never chosen anything gets: follow the system for both. */
export const DEFAULT_ACCOUNT_PREFERENCES: AccountPreferences = Object.freeze({
appearance: "system",
motion: "system",
});
/** A change to some of a person's preferences. Fields left out keep their stored value, so
* changing motion on a phone never overwrites an appearance chosen a moment earlier on a laptop. */
export type AccountPreferencesUpdate = Partial<AccountPreferences>;
const PREFERENCE_VALUES: Readonly<Record<keyof AccountPreferences, readonly string[]>> =
Object.freeze({
appearance: ACCOUNT_APPEARANCES,
motion: ACCOUNT_MOTION_PREFERENCES,
});
/** The preference names, in a stable order. */
export const ACCOUNT_PREFERENCE_NAMES = Object.freeze(
Object.keys(PREFERENCE_VALUES).sort() as (keyof AccountPreferences)[],
);
export function isAccountAppearance(value: unknown): value is AccountAppearance {
return typeof value === "string" && ACCOUNT_APPEARANCES.includes(value as AccountAppearance);
}
export function isAccountMotionPreference(value: unknown): value is AccountMotionPreference {
return (
typeof value === "string" &&
ACCOUNT_MOTION_PREFERENCES.includes(value as AccountMotionPreference)
);
}
/**
* Reads an untrusted update strictly: a plain object naming at least one known preference, each
* with one of its allowed values, and nothing else. Returns `undefined` for anything else — an
* unknown key, a wrong value, an empty object, an array, `null` — so a malformed request is refused
* whole rather than partly applied, and no unexpected key can ever reach storage.
*/
export function parseAccountPreferencesUpdate(
value: unknown,
): AccountPreferencesUpdate | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
if (Object.getPrototypeOf(value) !== Object.prototype) {
return undefined;
}
const entries = Object.entries(value);
if (entries.length === 0) {
return undefined;
}
const update: { appearance?: AccountAppearance; motion?: AccountMotionPreference } = {};
for (const [key, entry] of entries) {
if (key === "appearance" && isAccountAppearance(entry)) {
update.appearance = entry;
} else if (key === "motion" && isAccountMotionPreference(entry)) {
update.motion = entry;
} else {
return undefined;
}
}
return Object.freeze(update);
}
/** Resolves stored columns, where `null` means "never chosen", to the preferences in effect. A
* stored value the database constraint should have made impossible is treated as never chosen. */
export function resolveAccountPreferences(stored: {
readonly appearance: string | null;
readonly motion: string | null;
}): AccountPreferences {
return Object.freeze({
appearance: isAccountAppearance(stored.appearance)
? stored.appearance
: DEFAULT_ACCOUNT_PREFERENCES.appearance,
motion: isAccountMotionPreference(stored.motion)
? stored.motion
: DEFAULT_ACCOUNT_PREFERENCES.motion,
});
}
+56
View File
@@ -0,0 +1,56 @@
import { sql } from "drizzle-orm";
import { check, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { ekklesiaCoreUser } from "../identity/schema.js";
/**
* Core-owned storage for a person's own preferences (ADR 0062).
*
* **One row per person, one typed column per preference.** There is deliberately no key/value
* table and no JSON column: each preference has a fixed name and a closed set of values, enforced
* here by a `CHECK` constraint beneath the application-layer parser
* (`parseAccountPreferencesUpdate`), so nothing but a known value can be stored. A new preference is
* a new nullable column in a new migration.
*
* **`null` means "never chosen"**, and is resolved to the platform default when read
* (`resolveAccountPreferences`) rather than written as a column default. That keeps "this person
* chose System" distinguishable from "this person never chose", and lets a later preference column
* arrive without backfilling a value nobody chose — the same reasoning
* `ekklesia_core_module_state` follows for its absent rows (ADR 0028). A person with no row at all
* has chosen nothing.
*
* The row belongs to its person: `user_id` is the primary key and cascades on delete, so deleting
* an account removes its preferences with it, and no row can exist for a person who does not.
*
* Personal, not church-wide. Nothing here is branding, a church theme, or an administrator's
* decision for anyone else; those are Settings' (ADR 0061).
*/
export const ekklesiaCoreUserPreference = pgTable(
"ekklesia_core_user_preference",
{
userId: text("user_id")
.primaryKey()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
appearance: text("appearance"),
motion: text("motion"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
check(
"ekklesia_core_user_preference_appearance_check",
sql`${table.appearance} is null or ${table.appearance} in ('system', 'light', 'dark')`,
),
check(
"ekklesia_core_user_preference_motion_check",
sql`${table.motion} is null or ${table.motion} in ('system', 'reduce', 'allow')`,
),
],
);
/** Explicit map passed to Drizzle, mirroring `dashboardSchema` and `moduleLifecycleSchema`. The
* user table is included because the account summary reads a person's own name and email from it;
* nothing in this domain writes to it. */
export const accountSchema = {
user: ekklesiaCoreUser,
userPreference: ekklesiaCoreUserPreference,
} as const;
+156
View File
@@ -0,0 +1,156 @@
import type { EkklesiaUserId } from "@ekklesia/contracts";
import { eq, sql } from "drizzle-orm";
import { ekklesiaCoreUser } from "../identity/schema.js";
import type { AccountDrizzleDatabase } from "./database.js";
import {
type AccountPreferences,
type AccountPreferencesUpdate,
parseAccountPreferencesUpdate,
resolveAccountPreferences,
} from "./preferences.js";
import { ekklesiaCoreUserPreference } from "./schema.js";
export type AccountStoreErrorCode =
| "ACCOUNT_NOT_FOUND"
| "ACCOUNT_PREFERENCES_INVALID"
| "ACCOUNT_READ_FAILED"
| "ACCOUNT_WRITE_FAILED";
const accountStoreMessages: Readonly<Record<AccountStoreErrorCode, string>> = Object.freeze({
ACCOUNT_NOT_FOUND: "The account was not found.",
ACCOUNT_PREFERENCES_INVALID: "The preferences are not valid.",
ACCOUNT_READ_FAILED: "The account could not be read.",
ACCOUNT_WRITE_FAILED: "The preferences could not be saved.",
});
/** Never carries the underlying database error, SQL text, host, or stack — matching
* `DashboardStoreError` exactly. */
export class AccountStoreError extends Error {
readonly code: AccountStoreErrorCode;
constructor(code: AccountStoreErrorCode) {
super(accountStoreMessages[code]);
this.name = "AccountStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* What a signed-in person is shown about their own account: the name and email they signed up
* with, and their preferences. Deliberately nothing else — not the image URL, not verification
* state, not timestamps — so the one place the browser learns about an account carries exactly
* what the account area displays (ADR 0062).
*/
export interface AccountSummary {
readonly name: string;
readonly email: string;
readonly preferences: AccountPreferences;
}
/**
* A person's own account, and only their own: every method takes the one user id whose account it
* reads or writes, and the HTTP surface supplies it from the authenticated session, never from the
* request. There is no method that lists accounts or reads someone else's by any other key.
*/
export interface AccountStore {
/** The person's summary, or `undefined` when no such person exists. A person who never chose a
* preference gets the defaults. */
readAccount(userId: EkklesiaUserId): Promise<AccountSummary | undefined>;
/** Changes the named preferences and keeps the rest, returning everything now in effect. */
updatePreferences(
userId: EkklesiaUserId,
update: AccountPreferencesUpdate,
): Promise<AccountPreferences>;
}
function rethrowAsStoreError(error: unknown, code: AccountStoreErrorCode): never {
if (error instanceof AccountStoreError) {
throw error;
}
throw new AccountStoreError(code);
}
/**
* Core's persistence for a person's own account summary and preferences.
*
* Validation lives here as well as at the HTTP boundary, so the invariants hold for any caller: an
* update is re-read through the same strict parser, and a preference row can only be written for a
* person who exists.
*/
export function createAccountStore(db: AccountDrizzleDatabase): AccountStore {
return Object.freeze({
async readAccount(userId: EkklesiaUserId): Promise<AccountSummary | undefined> {
try {
const rows = await db
.select({
appearance: ekklesiaCoreUserPreference.appearance,
email: ekklesiaCoreUser.email,
motion: ekklesiaCoreUserPreference.motion,
name: ekklesiaCoreUser.name,
})
.from(ekklesiaCoreUser)
.leftJoin(
ekklesiaCoreUserPreference,
eq(ekklesiaCoreUserPreference.userId, ekklesiaCoreUser.id),
)
.where(eq(ekklesiaCoreUser.id, userId))
.limit(1);
const row = rows[0];
if (!row) {
return undefined;
}
return Object.freeze({
email: row.email,
name: row.name,
preferences: resolveAccountPreferences(row),
});
} catch (error) {
rethrowAsStoreError(error, "ACCOUNT_READ_FAILED");
}
},
async updatePreferences(
userId: EkklesiaUserId,
update: AccountPreferencesUpdate,
): Promise<AccountPreferences> {
const parsed = parseAccountPreferencesUpdate(update);
if (parsed === undefined) {
throw new AccountStoreError("ACCOUNT_PREFERENCES_INVALID");
}
try {
return await db.transaction(async (tx) => {
// Checked inside the transaction rather than left to the foreign key, so a person who
// does not exist is a clear "not found" instead of a constraint failure.
const people = await tx
.select({ id: ekklesiaCoreUser.id })
.from(ekklesiaCoreUser)
.where(eq(ekklesiaCoreUser.id, userId))
.limit(1);
if (people.length === 0) {
throw new AccountStoreError("ACCOUNT_NOT_FOUND");
}
const rows = await tx
.insert(ekklesiaCoreUserPreference)
.values({ userId, ...parsed })
.onConflictDoUpdate({
set: { ...parsed, updatedAt: sql`now()` },
target: ekklesiaCoreUserPreference.userId,
})
.returning({
appearance: ekklesiaCoreUserPreference.appearance,
motion: ekklesiaCoreUserPreference.motion,
});
const row = rows[0];
if (!row) {
throw new AccountStoreError("ACCOUNT_WRITE_FAILED");
}
return resolveAccountPreferences(row);
});
} catch (error) {
rethrowAsStoreError(error, "ACCOUNT_WRITE_FAILED");
}
},
});
}
+19
View File
@@ -169,6 +169,20 @@ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId
*/
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage");
/**
* Authenticated: every signed-in person may read their own name and email and read and change their
* own preferences (ADR 0062). Choosing dark mode is not church administration, so it needs no
* grant, and a Dashboard where it did would be unusable for its ordinary users. It is a declared
* permission rather than a hand-written "is signed in" check, as `core.modules.ui.read` is
* (ADR 0026), so the account area is authorized by the same evaluator as everything else.
*
* **It is self-only by construction.** The routes it guards take the account from the
* authenticated session and accept no user id at all, so holding it — whether through the baseline
* or a grant — confers no ability to read or change anyone else's account. A device never satisfies
* the `authenticated` baseline: a screen in the foyer has no account to read.
*/
export const CORE_ACCOUNT_SELF_MANAGE: PermissionId = parsePermissionId("core.account.self.manage");
/**
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
@@ -207,6 +221,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
description: "Approve display device pairing, list paired devices, and revoke a device.",
id: CORE_DEVICES_MANAGE,
}),
Object.freeze({
baseline: "authenticated" as const,
description: "Read your own account and read and change your own preferences.",
id: CORE_ACCOUNT_SELF_MANAGE,
}),
]);
/**
+13
View File
@@ -25,6 +25,7 @@ class VerifyArgumentsError extends Error {}
const USAGE =
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
export const MODULE_VERIFIER_SCRATCH_ENV = "EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR";
export interface VerifyModulePackageArguments {
readonly archivePath: string;
@@ -90,6 +91,16 @@ export function parseSha256File(contents: string): string {
return match[1].toLowerCase();
}
/** The CLI's operator-facing bridge to Core's otherwise explicit verifier option. */
export function moduleVerifierScratchDirectory(environment: NodeJS.ProcessEnv): string | undefined {
const configured = environment[MODULE_VERIFIER_SCRATCH_ENV];
if (configured === undefined) return undefined;
if (configured === "" || !path.isAbsolute(configured)) {
throw new VerifyArgumentsError(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
}
return configured;
}
async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
}
@@ -121,6 +132,7 @@ export async function readModulePackagePlatform(
async function main(): Promise<void> {
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
let expectedSha256 = parsed.expectedSha256;
if (parsed.sha256File !== undefined) {
let contents: string;
@@ -137,6 +149,7 @@ async function main(): Promise<void> {
const result = await verifyModulePackage({
archivePath: parsed.archivePath,
platform,
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
...(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;
/**
* How long a device credential is accepted before the device must pair again.
* How long a device credential may go **unused** before the device must pair again (ADR 0056).
*
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
* decides on every single request whether the device is still revoked, so an administrator's revoke
* takes effect immediately regardless of how long the credential would otherwise have lived. The
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
* The window slides: every request from the device moves `lastSeenAt` forward, and the host
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops
* being an identity at all: 180 days after it was last seen, its credential is dead.
*/
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
/** When a device credential expires: the idle window, measured from the last time the device was
* seen, or from pairing if it never has been. */
export function deviceCredentialExpiresAt(input: {
readonly createdAt: Date;
readonly lastSeenAt?: Date | null | undefined;
}): Date {
const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0);
return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS);
}
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
@@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
}
// The credential's own horizon, measured from when the device was paired. Revocation is
// checked above and is what makes a long horizon acceptable.
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
// Expiry is measured from last use, not from pairing, so a screen in use never ages out.
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
return undefined;
}
return Object.freeze({
+49 -2
View File
@@ -1,7 +1,54 @@
import type { ModuleDatabase } from "@ekklesia/contracts";
import { drizzle } from "drizzle-orm/node-postgres";
import type { ModuleDatabaseAbiRuntimeServices, ModuleRuntimeServices } from "@ekklesia/contracts";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
/**
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
* platform-owned Drizzle instance exposes, restricted to exactly the operations a module needs to
* read and write its own tables. Derived with `Pick` from the real `drizzle-orm/node-postgres`
* database type rather than hand-declared, so every method keeps Drizzle's own table-aware
* argument and result inference exactly as-is — a module calling `database.select().from(table)`
* or `database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
* would, with no `any`/`unknown` erasure anywhere in the chain.
*
* Deliberately excludes everything `NodePgDatabase` or the `drizzle(...)` factory otherwise
* exposes: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
* `query` (a relational-query builder tied to one schema, which would have to span every
* installed module's tables to exist here at all), `$cache`, `$count`, `$with`, `with`, and
* `refreshMaterializedView`. There is no connection string, pool, session, or dialect object
* reachable through this type. Carries no schema generic — a module brings its own Drizzle table
* objects to each call, so there is no global relational schema spanning every installed module for
* this type to leak.
*
* Core's own, private type (ADR 0053): it moved here from `@ekklesia/contracts` so no published
* package references Drizzle. New modules use the Module Database ABI v1 instead (ADR 0050), and
* the Module SDK never exposes this facade's type.
*
* This is an integration boundary, not a security sandbox: modules are trusted, compiled backend
* plugins that already run arbitrary code in this process, exactly like every other lifecycle hook
* and HTTP handler they define. `ModuleDatabase` exists to keep a module's code Drizzle-typed and
* decoupled from how the platform physically connects to PostgreSQL — not to defend against a
* malicious module, which this facade cannot do and does not attempt to do.
*/
export type ModuleDatabase = Pick<
NodePgDatabase,
| "select"
| "selectDistinct"
| "selectDistinctOn"
| "insert"
| "update"
| "delete"
| "execute"
| "transaction"
>;
/** The services a legacy module's hooks and handlers receive: the legacy facade. */
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
/** The services Core hands any started module: the legacy facade or the ABI v1 executor. */
export type AnyModuleRuntimeServices =
| LegacyModuleRuntimeServices
| ModuleDatabaseAbiRuntimeServices;
/**
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
+14
View File
@@ -9,6 +9,7 @@ import {
safeParseModuleManifest,
semanticVersionSchema,
} from "@ekklesia/contracts";
import { isReservedModuleId } from "./reserved-ids.js";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
@@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode =
| "MODULE_ENTRYPOINT_INVALID"
| "MODULE_ENTRYPOINT_MISSING"
| "MODULE_ID_DUPLICATE"
| "MODULE_ID_RESERVED"
| "MODULE_MANIFEST_INVALID"
| "MODULE_MANIFEST_MISSING"
| "MODULE_PACKAGE_INVALID"
@@ -614,6 +616,18 @@ async function scanModuleDirectory(
});
}
if (isReservedModuleId(manifest.id)) {
// A module's id is a stable URL identifier: its page is `/<module-id>` (ADR 0057) and its
// settings `/settings/<module-id>` (ADR 0061), beside the platform's own pages in both.
issues.push({
code: "MODULE_ID_RESERVED",
message: `Module ID ${manifest.id} is reserved for the platform's own pages and routes.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "id",
});
}
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!artifacts) {
return undefined;
+2
View File
@@ -9,9 +9,11 @@ export * from "./lifecycle-schema.js";
export * from "./lifecycle-store.js";
export * from "./migration-discovery.js";
export * from "./modules-directory.js";
export * from "./module-package-host.js";
export * from "./module-package-verification.js";
export * from "./permissions.js";
export * from "./realtime.js";
export * from "./reserved-ids.js";
export * from "./runtime.js";
export * from "./schema-discovery.js";
export * from "./ui-publication.js";
@@ -1,15 +1,15 @@
/**
* The legacy module schema definition (ADR 0007): a v8 module that declares no `database` default-
* exports `{ moduleId, tables }` with its Drizzle table objects themselves. Core alone validates it,
* for the legacy facade's deprecation window (ADR 0050). It lives here, beside Core's own Drizzle
* dependency, so that no package a module installs needs Drizzle at run time (ADR 0053).
*
* Erasable TypeScript with no relative imports: the verifier's probe imports this source file
* directly in tests (`module-package-probe.ts`).
*/
import { is } from "drizzle-orm";
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
import { type ModuleId, moduleIdSchema } from "./identifiers.js";
export {
moduleTableLocalNameSchema,
moduleTableName,
moduleTableNamespace,
moduleTablePrefix,
POSTGRES_IDENTIFIER_MAX_LENGTH,
physicalModuleTableNameSchema,
} from "./module-schema-names.js";
import { type ModuleId, moduleIdSchema } from "@ekklesia/contracts";
export interface ModuleSchemaDefinition<
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
+59
View File
@@ -0,0 +1,59 @@
/**
* The facts about this host that decide whether a native Module Package v1 can run on it
* (ADR 0049): operating system, CPU, C library family and version, and Node-API version.
*
* Everything comes from Node.js itself (`process.platform`, `process.arch`, `process.versions`, and
* the diagnostic report's `glibcVersionRuntime`), never from running a system command. A fact Node
* cannot report is left undefined, and every check that needs it fails closed.
*/
export interface ModulePackageHost {
readonly platform: string;
readonly arch: string;
/** The C library on Linux: glibc with its runtime version, or undefined when it cannot be proven
* to be glibc (musl reports none). */
readonly libc?: { readonly family: "glibc"; readonly version: string };
/** `process.versions.napi`: the highest Node-API version this Node.js provides. */
readonly nodeApi?: number;
/** `process.versions.modules`: the ABI an ABI-specific addon must have been built for. */
readonly nodeAbi?: string;
}
function glibcVersion(): string | undefined {
try {
const header = (process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } })
?.header;
const version = header?.glibcVersionRuntime;
return typeof version === "string" && /^\d+\.\d+(?:\.\d+)?$/u.test(version)
? version
: undefined;
} catch {
return undefined;
}
}
export function currentModulePackageHost(): ModulePackageHost {
const libc = process.platform === "linux" ? glibcVersion() : undefined;
const nodeApi = Number(process.versions.napi);
return Object.freeze({
arch: process.arch,
...(libc === undefined
? {}
: { libc: Object.freeze({ family: "glibc" as const, version: libc }) }),
...(Number.isInteger(nodeApi) && nodeApi > 0 ? { nodeApi } : {}),
...(typeof process.versions.modules === "string" ? { nodeAbi: process.versions.modules } : {}),
platform: process.platform,
});
}
/** The native targets this platform can verify, with what each requires of a host. */
export const MODULE_PACKAGE_NATIVE_TARGETS: ReadonlyMap<
string,
{
readonly platform: string;
readonly arch: string;
readonly libc: "glibc";
readonly elfMachine: number;
}
> = new Map([
["linux-x64-glibc", { arch: "x64", elfMachine: 62, libc: "glibc", platform: "linux" }],
]);
+4 -3
View File
@@ -9,14 +9,15 @@
// The result travels over the IPC channel, never stdout, so nothing a module prints can forge it.
// It carries fixed role and reason names only: never an error message, a path, or module output.
//
// Written as erasable TypeScript with no relative imports, so Node can run the source file directly
// in tests as well as the compiled file in the image.
// Written as erasable TypeScript, so Node can run the source file directly in tests as well as the
// compiled file in the image. Its one relative import names the source file's own extension, which
// the build rewrites to `.js` (`rewriteRelativeImportExtensions`); that file follows the same rules.
import { pathToFileURL } from "node:url";
import {
parseModuleRuntimeDefinition,
parseModuleSchemaDefinition,
parseModuleSchemaMetadataDefinition,
} from "@ekklesia/contracts";
import { parseModuleSchemaDefinition } from "./legacy-schema-definition.ts";
interface ProbeRequest {
readonly databaseAbi?: 1;
+167 -12
View File
@@ -1,6 +1,6 @@
import { fork } from "node:child_process";
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 path from "node:path";
import { fileURLToPath } from "node:url";
@@ -18,6 +18,11 @@ import {
safeParseModulePackageRuntimePackage,
} from "@ekklesia/contracts/module-package";
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
import {
currentModulePackageHost,
MODULE_PACKAGE_NATIVE_TARGETS,
type ModulePackageHost,
} from "./module-package-host.js";
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
import {
type ModulePackageArchive,
@@ -56,16 +61,20 @@ export type ModulePackageVerificationErrorCode =
| "MODULE_PACKAGE_FILE_UNLISTED"
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
| "MODULE_PACKAGE_LIBC_INCOMPATIBLE"
| "MODULE_PACKAGE_MANIFEST_INVALID"
| "MODULE_PACKAGE_METADATA_INVALID"
| "MODULE_PACKAGE_METADATA_MISSING"
| "MODULE_PACKAGE_MODULE_INVALID"
| "MODULE_PACKAGE_NATIVE_FILE"
| "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE"
| "MODULE_PACKAGE_NODE_API_INCOMPATIBLE"
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED";
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
@@ -85,12 +94,19 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
MODULE_PACKAGE_LAYOUT_INVALID:
"The archive must contain exactly one top-level module directory, with each path once.",
MODULE_PACKAGE_LIBC_INCOMPATIBLE:
"The package needs a newer C library than this host provides, or one it cannot establish.",
MODULE_PACKAGE_LINK: "The archive contains a link; module packages may contain none.",
MODULE_PACKAGE_MANIFEST_INVALID: "The package's module manifest is missing or invalid.",
MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.",
MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.",
MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.",
MODULE_PACKAGE_NATIVE_FILE: "An any-target package contains a native file.",
MODULE_PACKAGE_NATIVE_FILE:
"An any-target package contains a native file or a node_modules directory.",
MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE:
"A native file in the package is not built for the package's target.",
MODULE_PACKAGE_NODE_API_INCOMPATIBLE:
"The package's addons need a Node-API version or Node.js ABI this host does not provide.",
MODULE_PACKAGE_RUNTIME_INCOMPATIBLE:
"The package needs a Node.js version this host does not run.",
MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID:
@@ -103,6 +119,8 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
"The package metadata contains transitional facts this platform does not understand.",
MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE:
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
@@ -131,6 +149,8 @@ export interface ModulePackagePlatform {
readonly databaseRuntime: Readonly<Record<string, string>>;
/** Defaults to the running process's Node.js version. */
readonly nodeVersion?: string;
/** Defaults to this process's own host (`currentModulePackageHost`). */
readonly host?: ModulePackageHost;
}
export interface VerifyModulePackageOptions {
@@ -140,6 +160,12 @@ export interface VerifyModulePackageOptions {
readonly platform: ModulePackagePlatform;
/** A directory that must not exist yet; the verified module directory is written there. */
readonly extractTo?: string;
/**
* Existing writable directory where backend candidates may execute. Defaults to `os.tmpdir()`.
* A hardened caller whose system temporary directory is `noexec` must provide a dedicated
* executable scratch mount; each verification creates and removes its own child directory.
*/
readonly executionScratchDirectory?: string;
readonly loadTimeoutMs?: number;
}
@@ -266,12 +292,19 @@ function checkCompatibility(
files: ReadonlyMap<string, Buffer>,
platform: ModulePackagePlatform,
): void {
// This verifier implements the portable target only. Native targets are a later slice.
if (metadata.target !== MODULE_PACKAGE_ANY_TARGET) {
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
}
if ([...files.keys()].some((filePath) => filePath.toLowerCase().endsWith(".node"))) {
fail("MODULE_PACKAGE_NATIVE_FILE");
if (metadata.target === MODULE_PACKAGE_ANY_TARGET) {
// An any-target package bundles everything: no addon, no ELF object, no node_modules.
for (const [filePath, data] of files) {
if (
filePath.toLowerCase().endsWith(".node") ||
filePath.startsWith("node_modules/") ||
isElf(data)
) {
fail("MODULE_PACKAGE_NATIVE_FILE");
}
}
} else {
checkNativeTarget(metadata, files, platform.host ?? currentModulePackageHost());
}
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
@@ -296,6 +329,74 @@ function checkCompatibility(
}
}
function isElf(data: Uint8Array): boolean {
return (
data.length >= 4 && data[0] === 0x7f && data[1] === 0x45 && data[2] === 0x4c && data[3] === 0x46
);
}
/** `2.41` → `2.41.0`, for comparison against a semantic-version range. */
function asSemver(version: string): string {
const parts = version.split(".");
return [...parts, "0", "0"].slice(0, 3).join(".");
}
/**
* A native target (ADR 0049): accepted only for a target this platform implements, on a host that
* provably matches it. Platform, CPU, C library family and minimum version, and the Node-API version
* or ABI the addons need are all checked; a fact the host cannot establish fails closed. Every ELF
* object in the package must be built for the target's machine, and every `.node` addon must be one.
*/
function checkNativeTarget(
metadata: ModulePackageMetadata,
files: ReadonlyMap<string, Buffer>,
host: ModulePackageHost,
): void {
const target = MODULE_PACKAGE_NATIVE_TARGETS.get(metadata.target);
if (
target === undefined ||
host.platform !== target.platform ||
host.arch !== target.arch ||
host.libc?.family !== target.libc
) {
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
}
const required = metadata.runtime.libcVersion;
if (
required === undefined ||
host.libc === undefined ||
!isVersionCompatible(asSemver(host.libc.version), required)
) {
fail("MODULE_PACKAGE_LIBC_INCOMPATIBLE");
}
const { nodeAbi, nodeApi } = metadata.runtime;
if (
(nodeApi === undefined && nodeAbi === undefined) ||
(nodeApi !== undefined && (host.nodeApi === undefined || host.nodeApi < nodeApi)) ||
(nodeAbi !== undefined && host.nodeAbi !== nodeAbi)
) {
fail("MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
}
for (const [filePath, data] of files) {
const addon = filePath.toLowerCase().endsWith(".node");
if (!isElf(data)) {
if (addon) {
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
}
continue;
}
// ELF64, little-endian, for the target's machine (e_machine at offset 18).
if (
data.length < 20 ||
data[4] !== 2 ||
data[5] !== 1 ||
data.readUInt16LE(18) !== target.elfMachine
) {
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
}
}
}
/** Writes the archive's directories (`0755`) and files (`0644`) under `root`, which must not exist.
* Safe by construction: every path was validated as relative and non-escaping, and only directories
* and regular files exist. Ownership is the writing process's, never the archive's. */
@@ -322,12 +423,66 @@ function probeEntrypointPath(): string {
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
}
function decodedMountPath(value: string): string {
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
}
/** Linux exposes the effective options of every mount, including a container's tmpfs mounts. A
* verifier cannot infer `noexec` from file permissions: native addons are mapped executable rather
* than invoked as files. On systems without mountinfo, the load remains the final authority. */
async function isNoExecMount(directory: string): Promise<boolean> {
if (process.platform !== "linux") return false;
let mountinfo: string;
let canonicalDirectory: string;
try {
[mountinfo, canonicalDirectory] = await Promise.all([
readFile("/proc/self/mountinfo", "utf8"),
realpath(directory),
]);
} catch {
return false;
}
const mounts = mountinfo
.split("\n")
.map((line) => line.split(" "))
.filter((fields) => fields.length > 6 && fields.includes("-"))
.map((fields) => ({
mountPoint: decodedMountPath(fields[4] ?? ""),
options: (fields[5] ?? "").split(","),
}))
.filter(
({ mountPoint }) =>
canonicalDirectory === mountPoint ||
canonicalDirectory.startsWith(mountPoint === "/" ? "/" : `${mountPoint}/`),
)
.sort((left, right) => right.mountPoint.length - left.mountPoint.length);
return mounts[0]?.options.includes("noexec") ?? false;
}
async function createExecutionScratch(baseDirectory: string): Promise<string> {
if (!path.isAbsolute(baseDirectory)) {
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
try {
if (await isNoExecMount(baseDirectory)) {
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
return await mkdtemp(path.join(baseDirectory, "ekklesia-package-verify-"));
} catch (error) {
if (error instanceof ModulePackageVerificationError) throw error;
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
}
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
* only whether each loaded and exported a definition naming this module. */
async function loadEntrypoints(
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
entrypoints: Readonly<Record<string, string>>,
timeoutMs: number,
executionDirectory: string,
): Promise<void> {
const outcome = await new Promise<"ok" | "failed">((resolve) => {
let settled = false;
@@ -353,7 +508,7 @@ async function loadEntrypoints(
}),
],
{
cwd: tmpdir(),
cwd: executionDirectory,
env: {},
execArgv: [],
serialization: "json",
@@ -422,7 +577,7 @@ export async function verifyModulePackage(
const manifest = checkIdentity(archive, metadata, files);
checkCompatibility(metadata, manifest, files, options.platform);
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-"));
const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
try {
// The candidate alone, in a scratch inventory of its own: never module storage.
const modulesDirectory = path.join(scratch, "candidates");
@@ -458,7 +613,7 @@ export async function verifyModulePackage(
// Executed only now, and first in isolation: a schema is imported in this process below only
// after the probe proved it loads on its own.
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
try {
await discoverModuleSchemas(discoveryOptions);
} catch (error) {
+56
View File
@@ -0,0 +1,56 @@
/**
* Names the platform owns, which therefore can never be a module id (ADR 0057, ADR 0061).
*
* A module id is a **stable URL identifier**. Every module with a Dashboard UI is addressed at
* `/<module-id>`, and every module with settings at `/settings/<module-id>`, beside the platform's
* own pages in the same two namespaces. A module called `settings` would collide with the
* Dashboard's Settings, and one called `discover` with Settings → Discover. This is the complete set
* the platform serves today, not a guess at what it might want later.
*/
/**
* Top-level namespaces (`/<name>`):
*
* - `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/…`);
* - `display` — the display host's pages and service worker (`/display/…`);
* - `settings` and `account` — the Dashboard's own Settings and account areas.
*/
export const RESERVED_TOP_LEVEL_NAMES = Object.freeze([
"account",
"api",
"assets",
"display",
"settings",
] as const);
/**
* The platform's own Settings sections (`/settings/<name>`): General, Branding, Displays, Discover
* (finding, installing, and managing modules), and System. Every other `/settings/<name>` is a
* module's settings. Discover's own pages live beneath it (`/settings/discover/…`), so they cannot
* collide with a module and need no reservation of their own.
*/
export const RESERVED_SETTINGS_NAMES = Object.freeze([
"branding",
"discover",
"displays",
"general",
"system",
] as const);
/**
* Every reserved name, sorted. `apps/api` holds this list to the routes it actually registers and to
* the pages and Settings sections the shell interprets, so a new platform route or Settings section
* 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(
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(),
);
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);
export function isReservedModuleId(moduleId: string): boolean {
return reserved.has(moduleId);
}
+6 -8
View File
@@ -1,6 +1,5 @@
import { pathToFileURL } from "node:url";
import {
type AnyModuleRuntimeServices,
type CapabilityId,
type CapabilityRegistrar,
type CapabilityResolver,
@@ -8,7 +7,6 @@ import {
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleConfiguration,
type ModuleDatabase,
type ModuleDatabaseExecutor,
type ModuleHttpRouteRegistrar,
type ModuleId,
@@ -19,7 +17,6 @@ import {
type ModuleRouteHandler,
type ModuleRuntimeContext,
type ModuleRuntimeDefinition,
type ModuleRuntimeServices,
type ModuleStorage,
moduleRouteShapeKey,
moduleRoutesOverlap,
@@ -28,6 +25,7 @@ import {
type RegisteredModuleHttpRoute,
safeParseModuleRouteDefinition,
} from "@ekklesia/contracts";
import type { AnyModuleRuntimeServices, ModuleDatabase } from "./database.js";
import {
type DiscoveredModule,
discoverModules,
@@ -137,7 +135,7 @@ export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
/** One frozen service object per module id. Legacy modules may point at objects wrapping the same
* facade; ABI-v1 modules each point at their own attributed executor. */
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | ModuleRuntimeServices;
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
function servicesFor(
services: ModuleRuntimeServicesSource,
@@ -347,11 +345,11 @@ class StagedCapabilityRegistry {
createRuntimeContext(
module: DiscoveredModule,
phase: "rollback" | "start" | "stop",
services: ModuleRuntimeServices,
services: AnyModuleRuntimeServices,
config: ModuleConfiguration,
storage: ModuleStorage,
realtime: ModuleRealtimePublisher,
): ModuleRuntimeContext {
): ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor> {
const capabilities: CapabilityResolver = Object.freeze({
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
this.#assertAccess(module, capabilityId, phase);
@@ -754,7 +752,7 @@ async function stopModules(
registry.createRuntimeContext(
module.discovered,
phase,
servicesFor(services, module.discovered) as ModuleRuntimeServices,
servicesFor(services, module.discovered),
configurationFor(configurations, moduleId),
storageFor(storages, moduleId),
realtimeRegistry.publisherFor(moduleId),
@@ -814,7 +812,7 @@ async function startModules(
registry.createRuntimeContext(
module.discovered,
"start",
servicesFor(services, module.discovered) as ModuleRuntimeServices,
servicesFor(services, module.discovered),
configurationFor(configurations, module.discovered.manifest.id),
storageFor(storages, module.discovered.manifest.id),
realtimeRegistry.publisherFor(module.discovered.manifest.id),
+4 -2
View File
@@ -1,14 +1,16 @@
import { pathToFileURL } from "node:url";
import {
type ModuleId,
type ModuleSchemaDefinition,
type ModuleSchemaMetadataDefinition,
moduleTableLocalNameSchema,
moduleTablePrefix,
parseModuleSchemaDefinition,
parseModuleSchemaMetadataDefinition,
physicalModuleTableNameSchema,
} from "@ekklesia/contracts";
import {
type ModuleSchemaDefinition,
parseModuleSchemaDefinition,
} from "./legacy-schema-definition.js";
import { is } from "drizzle-orm";
import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core";
import {
+178
View File
@@ -0,0 +1,178 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import {
ACCOUNT_APPEARANCES,
ACCOUNT_MOTION_PREFERENCES,
ACCOUNT_PREFERENCE_NAMES,
AccountStoreError,
createAccountStore,
DEFAULT_ACCOUNT_PREFERENCES,
ekklesiaCoreUserPreference,
parseAccountPreferencesUpdate,
resolveAccountPreferences,
} from "../src/account/index.js";
const migrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
describe("account preferences model", () => {
it("defaults both preferences to following the system", () => {
expect(DEFAULT_ACCOUNT_PREFERENCES).toEqual({ appearance: "system", motion: "system" });
expect(Object.isFrozen(DEFAULT_ACCOUNT_PREFERENCES)).toBe(true);
});
it("declares a closed, ordered set of names and values", () => {
expect(ACCOUNT_PREFERENCE_NAMES).toEqual(["appearance", "motion"]);
expect(ACCOUNT_APPEARANCES).toEqual(["system", "light", "dark"]);
expect(ACCOUNT_MOTION_PREFERENCES).toEqual(["system", "reduce", "allow"]);
});
it.each(
ACCOUNT_APPEARANCES.map((appearance) => [appearance] as const),
)("accepts appearance %s on its own", (appearance) => {
expect(parseAccountPreferencesUpdate({ appearance })).toEqual({ appearance });
});
it.each(
ACCOUNT_MOTION_PREFERENCES.map((motion) => [motion] as const),
)("accepts motion %s on its own", (motion) => {
expect(parseAccountPreferencesUpdate({ motion })).toEqual({ motion });
});
it("accepts both preferences together", () => {
expect(parseAccountPreferencesUpdate({ appearance: "dark", motion: "reduce" })).toEqual({
appearance: "dark",
motion: "reduce",
});
});
it.each([
["an empty object", {}],
["null", null],
["an array", [["appearance", "dark"]]],
["a string", "dark"],
["an unknown appearance", { appearance: "sepia" }],
["an unknown motion", { motion: "none" }],
["a differently cased value", { appearance: "Dark" }],
["a non-string value", { appearance: 1 }],
["a null value", { motion: null }],
["an unknown key beside a valid one", { appearance: "dark", radius: "large" }],
["an unknown key alone", { theme: "dark" }],
["a user id", { appearance: "dark", userId: "someone-else" }],
["a prototype key", JSON.parse('{"__proto__": {"appearance": "dark"}}')],
[
"a class instance",
new (class Preferences {
appearance = "dark";
})(),
],
])("refuses %s whole", (_label, value) => {
expect(parseAccountPreferencesUpdate(value)).toBeUndefined();
});
it("resolves never-chosen columns to the defaults, and keeps a chosen value", () => {
expect(resolveAccountPreferences({ appearance: null, motion: null })).toEqual(
DEFAULT_ACCOUNT_PREFERENCES,
);
expect(resolveAccountPreferences({ appearance: "dark", motion: null })).toEqual({
appearance: "dark",
motion: "system",
});
// A value the constraint should have made impossible is never passed through.
expect(resolveAccountPreferences({ appearance: "neon", motion: "allow" })).toEqual({
appearance: "system",
motion: "allow",
});
});
});
describe("account preference storage", () => {
it("is one row per person, keyed and cascaded by user id, with one typed column per preference", () => {
const config = getTableConfig(ekklesiaCoreUserPreference);
expect(config.name).toBe("ekklesia_core_user_preference");
expect(config.columns.map((column) => column.name).sort()).toEqual([
"appearance",
"created_at",
"motion",
"updated_at",
"user_id",
]);
const userId = config.columns.find((column) => column.name === "user_id");
expect(userId?.primary).toBe(true);
// Preference columns are nullable: `null` is "never chosen", resolved to the default on read.
for (const name of ACCOUNT_PREFERENCE_NAMES) {
expect(config.columns.find((column) => column.name === name)?.notNull).toBe(false);
}
expect(config.foreignKeys).toHaveLength(1);
expect(config.foreignKeys[0]?.onDelete).toBe("cascade");
});
it("constrains each column in the migration to exactly the model's values", async () => {
const sql = await readFile(path.join(migrationsDirectory, "0008_user_preferences.sql"), "utf8");
const allowed = (column: string): string[] => {
const match = new RegExp(`"${column}" in \\(([^)]*)\\)`, "u").exec(sql);
return (match?.[1] ?? "").split(",").map((value) => value.trim().replaceAll("'", ""));
};
expect(allowed("appearance")).toEqual([...ACCOUNT_APPEARANCES]);
expect(allowed("motion")).toEqual([...ACCOUNT_MOTION_PREFERENCES]);
// No JSON, no key/value pair, nothing a client could fill with anything.
expect(sql).not.toMatch(/jsonb?|hstore/iu);
});
it("is a migration that destroys nothing", async () => {
const metadata = JSON.parse(
await readFile(path.join(migrationsDirectory, "meta", "_ekklesia.json"), "utf8"),
) as { entries: { tag: string; destructive: { mode: string } }[] };
expect(metadata.entries.find((entry) => entry.tag === "0008_user_preferences")).toEqual({
destructive: { mode: "none" },
tag: "0008_user_preferences",
});
});
});
describe("account store validation", () => {
it("refuses an invalid update before touching the database", async () => {
const untouchable = new Proxy(
{},
{
get() {
throw new Error("the database must not be reached");
},
},
);
const store = createAccountStore(untouchable as never);
await expect(
store.updatePreferences("user-1" as never, { appearance: "sepia" } as never),
).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID", name: "AccountStoreError" });
await expect(store.updatePreferences("user-1" as never, {})).rejects.toBeInstanceOf(
AccountStoreError,
);
});
it("reports a database failure without the underlying error", async () => {
const failing = {
select() {
throw new Error("connect ECONNREFUSED postgres://secret@db:5432");
},
transaction() {
throw new Error("connect ECONNREFUSED postgres://secret@db:5432");
},
};
const store = createAccountStore(failing as never);
const read = await store.readAccount("user-1" as never).catch((error: unknown) => error);
expect(read).toBeInstanceOf(AccountStoreError);
expect(read).toMatchObject({ code: "ACCOUNT_READ_FAILED" });
expect(String((read as Error).stack)).not.toContain("secret");
const write = await store
.updatePreferences("user-1" as never, { appearance: "dark" })
.catch((error: unknown) => error);
expect(write).toMatchObject({ code: "ACCOUNT_WRITE_FAILED" });
expect(String((write as Error).message)).not.toContain("ECONNREFUSED");
});
});
+378
View File
@@ -0,0 +1,378 @@
import { randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { EkklesiaUserId } from "@ekklesia/contracts";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import {
type AccountStore,
AccountStoreError,
createAccountDrizzleView,
createAccountStore,
} from "../src/account/index.js";
import { createValidatedPostgresPool } from "../src/database/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
/**
* A person's own account summary and preferences against real PostgreSQL (ADR 0062).
*
* The unit suite proves the model and parser; this proves what only a database can: that the
* migration creates the table with its constraints, that a choice survives a completely new
* connection (which is what "follows you to another device" rests on), that one person's change
* never touches another's row, that the database itself refuses a value outside the model, and
* that deleting a person deletes their preferences.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function harnessError(message: string): IntegrationHarnessError {
return new IntegrationHarnessError(message);
}
function baseDatabaseUrl(): string {
const value = process.env[integrationDatabaseUrlEnvironment];
if (typeof value !== "string" || value.trim() === "") {
throw harnessError("The account integration database is not configured.");
}
try {
const parsed = new URL(value);
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
throw new TypeError("Unsupported protocol.");
}
} catch {
throw harnessError("The account integration database configuration is invalid.");
}
return value;
}
function quoteDatabaseName(databaseName: string): string {
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
throw harnessError("The generated integration database name is invalid.");
}
return `"${databaseName}"`;
}
function makeDatabaseName(label: string): string {
const safeLabel = label
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
.slice(0, 14);
databaseCounter += 1;
return `ekk_acct_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
}
function databaseUrl(databaseName: string): string {
const parsed = new URL(baseDatabaseUrl());
parsed.pathname = `/${databaseName}`;
parsed.searchParams.delete("application_name");
return parsed.toString();
}
async function openAdministrativeClient(connectionString: string): Promise<Client> {
const client = new Client({ connectionString });
client.on("error", () => undefined);
try {
await client.connect();
return client;
} catch {
try {
await client.end();
} catch {
// The public harness failure stays static when connection cleanup also fails.
}
throw harnessError("The account integration database connection failed.");
}
}
async function safeQuery<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
async function dropDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(
admin,
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
[databaseName],
);
await safeQuery(
admin,
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
);
trackedDatabases.delete(databaseName);
} finally {
await safeEnd(admin);
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`);
} finally {
await safeEnd(admin);
}
trackedDatabases.add(name);
return { name, url: databaseUrl(name) };
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-acct-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModulesDirectory, { force: true, recursive: true });
}
}
afterAll(async () => {
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
}
});
const ada = "user-ada" as EkklesiaUserId;
const grace = "user-grace" as EkklesiaUserId;
const nobody = "user-nobody" as EkklesiaUserId;
async function insertPerson(
client: Client,
id: string,
name: string,
email: string,
): Promise<void> {
await safeQuery(
client,
"insert into ekklesia_core_user (id, name, email, email_verified) values ($1, $2, $3, true)",
[id, name, email],
);
}
describe.sequential("live Core account store", () => {
let database: { name: string; url: string };
let admin: Client;
let store: AccountStore;
let close: () => Promise<void>;
it("creates the preference table with its constraints", async () => {
database = await createDisposableDatabase("fresh");
await applyCoreMigrations(database.url);
admin = await openAdministrativeClient(database.url);
const constraints = await safeQuery<{ constraint_name: string; constraint_type: string }>(
admin,
`select constraint_name, constraint_type from information_schema.table_constraints
where table_name = 'ekklesia_core_user_preference' and constraint_type <> 'CHECK'
or constraint_name like 'ekklesia_core_user_preference_%_check'
order by constraint_name`,
);
const deleteRules = await safeQuery<{ delete_rule: string }>(
admin,
`select rc.delete_rule from information_schema.referential_constraints rc
join information_schema.table_constraints tc on tc.constraint_name = rc.constraint_name
where tc.table_name = 'ekklesia_core_user_preference'`,
);
expect(constraints.map((constraint) => constraint.constraint_name)).toEqual([
"ekklesia_core_user_preference_appearance_check",
"ekklesia_core_user_preference_motion_check",
"ekklesia_core_user_preference_pkey",
"ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk",
]);
expect(deleteRules.map((rule) => rule.delete_rule)).toEqual(["CASCADE"]);
await insertPerson(admin, ada, "Ada Lovelace", "[email protected]");
await insertPerson(admin, grace, "Grace Hopper", "[email protected]");
const handle = createValidatedPostgresPool(database.url);
close = handle.close;
store = createAccountStore(createAccountDrizzleView(handle.pool));
});
it("reads a person who never chose anything with their name, email, and the defaults", async () => {
expect(await store.readAccount(ada)).toEqual({
email: "[email protected]",
name: "Ada Lovelace",
preferences: { appearance: "system", motion: "system" },
});
const rows = await safeQuery(admin, "select * from ekklesia_core_user_preference");
// Reading never materializes a row nobody chose.
expect(rows).toHaveLength(0);
});
it("answers undefined for a person who does not exist", async () => {
expect(await store.readAccount(nobody)).toBeUndefined();
});
it.each([
["appearance", "light"],
["appearance", "dark"],
["appearance", "system"],
["motion", "reduce"],
["motion", "allow"],
["motion", "system"],
] as const)("saves %s %s and reads it back", async (name, value) => {
const saved = await store.updatePreferences(ada, { [name]: value });
expect(saved[name]).toBe(value);
expect((await store.readAccount(ada))?.preferences[name]).toBe(value);
});
it("changes only the preference named, keeping the other", async () => {
await store.updatePreferences(ada, { appearance: "dark", motion: "reduce" });
expect(await store.updatePreferences(ada, { motion: "allow" })).toEqual({
appearance: "dark",
motion: "allow",
});
expect(await store.updatePreferences(ada, { appearance: "light" })).toEqual({
appearance: "light",
motion: "allow",
});
});
it("stores a first partial choice with the other preference still never chosen", async () => {
expect(await store.updatePreferences(grace, { motion: "reduce" })).toEqual({
appearance: "system",
motion: "reduce",
});
const rows = await safeQuery<{ appearance: string | null; motion: string | null }>(
admin,
"select appearance, motion from ekklesia_core_user_preference where user_id = $1",
[grace],
);
expect(rows).toEqual([{ appearance: null, motion: "reduce" }]);
});
it("keeps each person's preferences to themselves", async () => {
await store.updatePreferences(ada, { appearance: "dark", motion: "allow" });
await store.updatePreferences(grace, { appearance: "light", motion: "reduce" });
expect((await store.readAccount(ada))?.preferences).toEqual({
appearance: "dark",
motion: "allow",
});
expect(await store.readAccount(grace)).toEqual({
email: "[email protected]",
name: "Grace Hopper",
preferences: { appearance: "light", motion: "reduce" },
});
});
it("survives a completely new connection, as another device signing in would see", async () => {
const reopened = createValidatedPostgresPool(database.url);
try {
const other = createAccountStore(createAccountDrizzleView(reopened.pool));
expect((await other.readAccount(ada))?.preferences).toEqual({
appearance: "dark",
motion: "allow",
});
} finally {
await reopened.close();
}
});
it("refuses to store preferences for a person who does not exist", async () => {
const failure = await store
.updatePreferences(nobody, { appearance: "dark" })
.catch((error: unknown) => error);
expect(failure).toBeInstanceOf(AccountStoreError);
expect(failure).toMatchObject({ code: "ACCOUNT_NOT_FOUND" });
const rows = await safeQuery(
admin,
"select 1 from ekklesia_core_user_preference where user_id = $1",
[nobody],
);
expect(rows).toHaveLength(0);
});
it("refuses an invalid update with nothing written", async () => {
await expect(
store.updatePreferences(ada, { appearance: "sepia" } as never),
).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID" });
expect((await store.readAccount(ada))?.preferences.appearance).toBe("dark");
});
it("has the database itself refuse a value outside the model", async () => {
await expect(
admin.query(
"update ekklesia_core_user_preference set appearance = 'sepia' where user_id = $1",
[ada],
),
).rejects.toMatchObject({ code: "23514" });
await expect(
admin.query("update ekklesia_core_user_preference set motion = 'none' where user_id = $1", [
ada,
]),
).rejects.toMatchObject({ code: "23514" });
});
it("deletes a person's preferences with the person", async () => {
await safeQuery(admin, "delete from ekklesia_core_user where id = $1", [grace]);
const rows = await safeQuery(
admin,
"select 1 from ekklesia_core_user_preference where user_id = $1",
[grace],
);
expect(rows).toHaveLength(0);
expect(await store.readAccount(grace)).toBeUndefined();
await safeEnd(admin);
await close();
});
});
+14 -1
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_ACCOUNT_SELF_MANAGE,
CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
@@ -133,7 +134,13 @@ describe("CORE_PERMISSION_REGISTRY", () => {
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
});
it("contains exactly these six permissions and no others", () => {
it("registers core.account.self.manage as authenticated: managing your own account needs no grant", () => {
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_ACCOUNT_SELF_MANAGE);
expect(declaration?.baseline).toBe("authenticated");
expect(declaration?.id).toBe("core.account.self.manage");
});
it("contains exactly these seven permissions and no others", () => {
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ,
@@ -141,6 +148,7 @@ describe("CORE_PERMISSION_REGISTRY", () => {
CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_DEVICES_MANAGE,
CORE_ACCOUNT_SELF_MANAGE,
]);
});
});
@@ -178,6 +186,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
description: "Approve display device pairing, list paired devices, and revoke a device.",
id: CORE_DEVICES_MANAGE,
},
{
baseline: "authenticated",
description: "Read your own account and read and change your own preferences.",
id: CORE_ACCOUNT_SELF_MANAGE,
},
]);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
@@ -243,6 +244,8 @@ describe.sequential("live Core authorization store", () => {
"ekklesia_core_platform_owner_bootstrap",
"ekklesia_core_session",
"ekklesia_core_user",
// A person's own preferences, one typed column each (ADR 0062).
"ekklesia_core_user_preference",
"ekklesia_core_verification",
]);
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
+1 -1
View File
@@ -92,7 +92,7 @@ Modules
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
6 permissions registered
7 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
○ Module configuration skipped
@@ -5,8 +5,8 @@
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
"version": "0.1.0",
"compatibility": {
"platform": "^0.1.0",
"moduleSdk": "^0.1.0"
"platform": "^0.2.0",
"moduleSdk": "^0.2.0"
},
"permissions": [
{
@@ -169,13 +169,57 @@ describe("the browser shell reaches none of it", () => {
});
it("never puts a reset token in browser storage", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
// A reset token is a bearer credential for one account; the shell holds it in React state for
// the life of one form and nowhere else. The shell's uses of browser storage are pinned
// exactly: the desktop sidebar's expanded/collapsed mode (ADR 0058) and the pre-paint copy of
// the person's appearance (ADR 0062). `localStorage` is named only by the entry point, once, to
// hand to those two, and by the pre-paint script, once, to read its one key. Each write is its
// own fixed key and a validated value.
const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src"));
const writes: string[] = [];
const removals: string[] = [];
for (const file of files) {
const code = withoutComments(file.text);
// A reset token is a bearer credential for one account; the shell holds it in React state for
// the life of one form and nowhere else.
expect(code, file.path).not.toContain("localStorage");
expect(code, file.path).not.toContain("sessionStorage");
expect(code, file.path).not.toContain("document.cookie");
writes.push(
...[...code.matchAll(/\.setItem\(([^)]*)\)/gu)].map(
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
),
);
removals.push(
...[...code.matchAll(/\.removeItem\(([^)]*)\)/gu)].map(
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
),
);
if (file.path.endsWith(path.join("browser", "main.tsx"))) {
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
expect(code).toMatch(
/const browserStorage = \(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
);
expect(code).toContain("createLocalSidebarPreference(browserStorage)");
expect(code).toContain("createLocalAppearanceCache(browserStorage)");
} else if (file.path.endsWith(path.join("browser", "appearance-boot.ts"))) {
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
expect(code).toContain("window.localStorage.getItem(APPEARANCE_CACHE_KEY)");
} else {
expect(code, file.path).not.toContain("localStorage");
}
}
expect(writes).toEqual([
`${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY, serialized`,
`${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`,
]);
expect(removals).toEqual([
`${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY`,
]);
// The files that ever hold the token touch no storage API at all.
for (const file of files.filter((candidate) =>
["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) =>
candidate.path.endsWith(name),
),
)) {
expect(withoutComments(file.text), file.path).not.toMatch(/\b(?:setItem|getItem|Storage)\b/u);
}
});
});
@@ -0,0 +1,34 @@
import { alias, getTableConfig, integer, pgTable } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import { parseModuleSchemaDefinition } from "../src/modules/legacy-schema-definition.js";
describe("the legacy module schema definition", () => {
it("normalizes and freezes a typed schema definition without freezing its tables", () => {
const records = pgTable("example_records", { id: integer().primaryKey() });
const definition = parseModuleSchemaDefinition({
moduleId: "example",
tables: { records },
});
expect(definition.tables.records).toBe(records);
expect(getTableConfig(records).name).toBe("example_records");
expect(Object.isFrozen(definition)).toBe(true);
expect(Object.isFrozen(definition.tables)).toBe(true);
expect(Object.isFrozen(records)).toBe(false);
});
it("rejects malformed, empty, unknown, and non-PostgreSQL table definitions", () => {
const records = pgTable("example_records", { id: integer() });
const disguisedRecords = alias(records, "example_disguised_records");
expect(() => parseModuleSchemaDefinition({ moduleId: "example", tables: {} })).toThrow();
expect(() =>
parseModuleSchemaDefinition({ moduleId: "example", tables: { records: {} } }),
).toThrow();
expect(() =>
parseModuleSchemaDefinition({ moduleId: "example", tables: { records }, extra: true }),
).toThrow();
expect(() =>
parseModuleSchemaDefinition({ moduleId: "example", tables: { disguisedRecords } }),
).toThrow();
});
});
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
@@ -1,8 +1,8 @@
import { integer, pgTable, text } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import type { ModuleDatabase, ModuleRuntimeServices } from "../src/index.js";
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
const testTable = pgTable("contracts_module_database_test", {
const testTable = pgTable("core_module_database_type_test", {
id: integer("id").primaryKey(),
name: text("name").notNull(),
});
@@ -68,16 +68,17 @@ function typeOnlyExclusions(database: ModuleDatabase): void {
}
void typeOnlyExclusions;
/** Never invoked — proves `ModuleRuntimeServices` has exactly one field, `database`, typed as
* `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization field this phase. */
function typeOnlyServicesShape(services: ModuleRuntimeServices): void {
/** Never invoked — proves a legacy module's `ModuleRuntimeServices` has exactly one field,
* `database`, typed as `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization
* field this phase. */
function typeOnlyServicesShape(services: LegacyModuleRuntimeServices): void {
const database: ModuleDatabase = services.database;
void database;
// @ts-expect-error `storage` does not exist on `ModuleRuntimeServices` yet.
// @ts-expect-error `storage` does not exist on the services yet.
void services.storage;
// @ts-expect-error `jobs` does not exist on `ModuleRuntimeServices` yet.
// @ts-expect-error `jobs` does not exist on the services yet.
void services.jobs;
// @ts-expect-error `authorization` does not exist on `ModuleRuntimeServices` yet.
// @ts-expect-error `authorization` does not exist on the services yet.
void services.authorization;
}
void typeOnlyServicesShape;
+87
View File
@@ -4,8 +4,12 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
discoverModules,
isReservedModuleId,
ModuleDiscoveryError,
type ModuleDiscoveryIssueCode,
RESERVED_MODULE_IDS,
RESERVED_SETTINGS_NAMES,
RESERVED_TOP_LEVEL_NAMES,
} from "../src/modules/index.js";
interface ModuleFixtureOptions {
@@ -271,6 +275,89 @@ 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_TOP_LEVEL_NAMES).toEqual(["account", "api", "assets", "display", "settings"]);
// A module's settings live at `/settings/<module-id>` (ADR 0061), beside the platform's own
// Settings sections, so their names are reserved too.
expect(RESERVED_SETTINGS_NAMES).toEqual([
"branding",
"discover",
"displays",
"general",
"system",
]);
expect(RESERVED_MODULE_IDS).toEqual([
"account",
"api",
"assets",
"branding",
"discover",
"display",
"displays",
"general",
"settings",
"system",
]);
for (const list of [RESERVED_MODULE_IDS, RESERVED_TOP_LEVEL_NAMES, RESERVED_SETTINGS_NAMES]) {
expect(Object.isFrozen(list)).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();
// `modules` is an ordinary id again: module management is Settings → Discover.
for (const id of [
"settings-sync",
"my-account",
"display-board",
"api-docs",
"general-info",
"modules",
"discovery",
"systems",
]) {
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",
"discovery",
"display-board",
"general-info",
"modules",
"my-account",
"settings-sync",
"systems",
]);
expect(isReservedModuleId("settings")).toBe(true);
expect(isReservedModuleId("settings-sync")).toBe(false);
});
it("enforces platform and Module SDK compatibility", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "future", {
@@ -182,6 +182,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
@@ -35,9 +35,9 @@ async function createModulesDirectory(): Promise<string> {
function discoveryOptions(modulesDirectory: string) {
return {
moduleSdkVersion: "0.1.0",
moduleSdkVersion: "0.2.0",
modulesDirectory,
platformVersion: "0.1.0",
platformVersion: "0.2.0",
};
}
+231 -4
View File
@@ -1,8 +1,10 @@
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 path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
currentModulePackageHost,
type ModulePackageHost,
ModulePackageVerificationError,
type ModulePackageVerificationErrorCode,
verifyModulePackage,
@@ -16,8 +18,8 @@ import {
const platform = {
databaseRuntime: { "drizzle-orm": "0.45.2" },
moduleSdkVersion: "0.1.2",
platformVersion: "0.1.2",
moduleSdkVersion: "0.2.0",
platformVersion: "0.2.0",
} as const;
const temporaryDirectories: string[] = [];
@@ -46,6 +48,12 @@ async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
return { archivePath, sha256: fixture.sha256 };
}
async function executionScratch(): Promise<string> {
const directory = path.join(await scratch(), "execution");
await mkdir(directory);
return directory;
}
async function rejection(
options: PackageFixtureOptions,
code: ModulePackageVerificationErrorCode,
@@ -98,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 () => {
const { archivePath } = await archiveFile();
const target = path.join(await scratch(), "example");
@@ -354,7 +408,7 @@ describe("Module Package v1 verification (ADR 0049)", () => {
metadata: (metadata) => ({
...metadata,
target: "linux-arm64-musl",
runtime: { node: ">=20", nodeApi: 9 },
runtime: { libcVersion: ">=1.2.0", node: ">=20", nodeApi: 9 },
}),
},
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
@@ -493,6 +547,179 @@ describe("Module Package v1 verification (ADR 0049)", () => {
});
});
describe("native targets (ADR 0049)", () => {
/** The first bytes of a 64-bit little-endian ELF object for `machine` (62 is x86-64). The
* verifier reads only the identification and machine fields. */
function elfHeader(machine = 62): Buffer {
const header = Buffer.alloc(64);
Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(header);
header.writeUInt16LE(3, 16);
header.writeUInt16LE(machine, 18);
return header;
}
const linuxHost = {
arch: "x64",
libc: { family: "glibc", version: "2.41" },
nodeApi: 10,
nodeAbi: "137",
platform: "linux",
} as const;
function nativePackage(
overrides: {
readonly files?: Record<string, Uint8Array | string>;
readonly target?: string;
readonly runtime?: Record<string, unknown>;
} = {},
): PackageFixtureOptions {
return {
files: overrides.files ?? {
"node_modules/native-thing/index.js": "module.exports = {};\n",
"node_modules/native-thing/lib/native-thing.node": elfHeader(),
"node_modules/native-thing/package.json": '{"name":"native-thing","version":"1.0.0"}\n',
},
metadata: (metadata) => ({
...metadata,
runtime: overrides.runtime ?? {
libcVersion: ">=2.28.0",
node: ">=20",
nodeApi: 9,
},
target: overrides.target ?? "linux-x64-glibc",
}),
};
}
async function verifyOn(
options: PackageFixtureOptions,
host: ModulePackageHost,
): Promise<unknown> {
const { archivePath } = await archiveFile(options);
return verifyModulePackage({ archivePath, platform: { ...platform, host } });
}
async function refusedOn(
options: PackageFixtureOptions,
host: ModulePackageHost,
code: ModulePackageVerificationErrorCode,
): Promise<void> {
await expect(verifyOn(options, host)).rejects.toMatchObject({ code });
}
it("accepts linux-x64-glibc on a matching host, native node_modules and addon included", async () => {
await expect(verifyOn(nativePackage(), linuxHost)).resolves.toMatchObject({
moduleId: "example",
target: "linux-x64-glibc",
});
});
it("refuses another operating system, CPU, or C library family", async () => {
await refusedOn(
nativePackage(),
{ ...linuxHost, platform: "darwin" },
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
);
await refusedOn(
nativePackage(),
{ ...linuxHost, arch: "arm64" },
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
);
// musl reports no glibc version, so the host cannot be proven glibc.
const { libc: _libc, ...musl } = linuxHost;
await refusedOn(nativePackage(), musl, "MODULE_PACKAGE_TARGET_INCOMPATIBLE");
});
it("refuses a target this platform does not implement", async () => {
await refusedOn(
nativePackage({ target: "linux-arm64-glibc" }),
{ ...linuxHost, arch: "arm64" },
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
);
});
it("refuses a C library older than the package needs", async () => {
await refusedOn(
nativePackage(),
{ ...linuxHost, libc: { family: "glibc", version: "2.17" } },
"MODULE_PACKAGE_LIBC_INCOMPATIBLE",
);
await expect(
verifyOn(nativePackage(), { ...linuxHost, libc: { family: "glibc", version: "2.28" } }),
).resolves.toMatchObject({ target: "linux-x64-glibc" });
});
it("refuses a Node-API version or Node.js ABI the host does not provide", async () => {
await refusedOn(
nativePackage(),
{ ...linuxHost, nodeApi: 8 },
"MODULE_PACKAGE_NODE_API_INCOMPATIBLE",
);
const { nodeApi: _nodeApi, ...unknownApi } = linuxHost;
await refusedOn(nativePackage(), unknownApi, "MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
await refusedOn(
nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20", nodeAbi: "115" } }),
linuxHost,
"MODULE_PACKAGE_NODE_API_INCOMPATIBLE",
);
});
it("refuses a native file built for another machine, or an addon that is not one", async () => {
await refusedOn(
nativePackage({
files: { "node_modules/native-thing/lib/native-thing.node": elfHeader(183) },
}),
linuxHost,
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
);
await refusedOn(
nativePackage({
files: { "node_modules/native-thing/lib/libthing.so": elfHeader(183) },
}),
linuxHost,
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
);
await refusedOn(
nativePackage({
files: { "node_modules/native-thing/lib/native-thing.node": "not an ELF object" },
}),
linuxHost,
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
);
});
it("requires a native package to state its Node.js interface and, on Linux, its C library", async () => {
await rejection(
nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20" } }),
"MODULE_PACKAGE_METADATA_INVALID",
);
await rejection(
nativePackage({ runtime: { node: ">=20", nodeApi: 9 } }),
"MODULE_PACKAGE_METADATA_INVALID",
);
});
it("keeps any-target packages free of node_modules, addons, and ELF objects", async () => {
await rejection(
{ files: { "node_modules/thing/index.js": "module.exports = {};\n" } },
"MODULE_PACKAGE_NATIVE_FILE",
);
await rejection({ files: { "dist/thing.node": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE");
await rejection({ files: { "dist/libthing.so": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE");
});
it("establishes this host's facts from Node.js alone", () => {
const host = currentModulePackageHost();
expect(host.platform).toBe(process.platform);
expect(host.arch).toBe(process.arch);
expect(host.nodeApi).toBe(Number(process.versions.napi));
if (process.platform === "linux") {
const report = process.report.getReport() as { header?: { glibcVersionRuntime?: string } };
expect(host.libc?.version).toBe(report.header?.glibcVersionRuntime);
}
});
});
it("fails an archive without package metadata", async () => {
const fixture = await modulePackageFixture();
const { gunzipSync, gzipSync } = await import("node:zlib");
+5 -2
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_ACCOUNT_SELF_MANAGE,
CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
@@ -75,8 +76,9 @@ describe("composePlatformPermissionRegistry", () => {
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
// Core's own five declarations plus one from each module.
expect(registry.declarations).toHaveLength(8);
expect(registry.has(CORE_ACCOUNT_SELF_MANAGE)).toBe(true);
// Core's own seven declarations plus one from each module.
expect(registry.declarations).toHaveLength(9);
});
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
@@ -97,6 +99,7 @@ describe("composePlatformPermissionRegistry", () => {
CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_DEVICES_MANAGE,
CORE_ACCOUNT_SELF_MANAGE,
"calendar.events.read",
"calendar.events.write",
"people.directory.read",
+3 -6
View File
@@ -1,11 +1,8 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type {
ModuleDatabase,
ModuleRealtimePublisher,
ModuleRuntimeServices,
} from "@ekklesia/contracts";
import type { ModuleRealtimePublisher } from "@ekklesia/contracts";
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
import { afterEach, describe, expect, it } from "vitest";
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
@@ -22,7 +19,7 @@ afterEach(async () => {
);
});
function fakeServices(): ModuleRuntimeServices {
function fakeServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
}
+4 -5
View File
@@ -1,13 +1,12 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { ModuleConfiguration, ModuleDatabaseExecutor } from "@ekklesia/contracts";
import type {
AnyModuleRuntimeServices,
ModuleConfiguration,
LegacyModuleRuntimeServices,
ModuleDatabase,
ModuleDatabaseExecutor,
ModuleRuntimeServices,
} from "@ekklesia/contracts";
} from "../src/modules/database.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { discoverModules } from "../src/modules/discovery.js";
import {
@@ -151,7 +150,7 @@ function fakeModuleDatabase(): ModuleDatabase {
}
function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
const services = new Map<string, ModuleRuntimeServices>();
const services = new Map<string, LegacyModuleRuntimeServices>();
const database = fakeModuleDatabase();
return new Proxy(services, {
get(target, property, receiver) {
+12
View File
@@ -1,5 +1,6 @@
import {
EMPTY_MODULE_STORAGE,
isModuleStorageError,
isValidModuleStorageKey,
MODULE_STORAGE_KEY_MAX_BYTES,
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
}
});
it("recognizes a storage error from an installed module's separate package copy", () => {
const external = Object.assign(new Error("The storage service could not be reached."), {
code: "STORAGE_PROVIDER_UNAVAILABLE",
name: "ModuleStorageError",
});
expect(external).not.toBeInstanceOf(ModuleStorageError);
expect(isModuleStorageError(external)).toBe(true);
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
});
it("gives a module no way to reach another module's storage by knowing its name", async () => {
const shared = fakeProvider();
const announcements = createModuleStorage({
+1 -1
View File
@@ -1905,7 +1905,7 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const templateModule = path.join(repositoryRoot, "templates", "module");
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
const versions = { moduleSdkVersion: "0.2.0", platformVersion: "0.2.0" } as const;
async function coreWithoutNewestMigration(directory: string): Promise<string> {
const target = path.join(directory, "core-drizzle");
@@ -54,7 +54,7 @@ export function manifestFor(
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
...(options.dependencies ? { dependencies: options.dependencies } : {}),
capabilities: [],
permissions: [],
+7 -2
View File
@@ -94,6 +94,8 @@ export interface PackageFixtureOptions {
readonly tamper?: (files: Map<string, Buffer>) => void;
/** Entries appended after the package's own, under any path. */
readonly extraEntries?: readonly FixtureEntry[];
/** Extra payload files, listed in the metadata like every other file. */
readonly files?: Readonly<Record<string, Uint8Array | string>>;
}
export interface PackageFixture {
@@ -163,6 +165,9 @@ export async function modulePackageFixture(
);
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
files.set("LICENSE", Buffer.from("Example module license text.\n"));
for (const [filePath, data] of Object.entries(options.files ?? {})) {
files.set(filePath, Buffer.from(data));
}
const listed = [...files.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
@@ -175,9 +180,9 @@ export async function modulePackageFixture(
runtime: { node: ">=20" },
provenance: {
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
packager: { name: "@ekklesia/module-pack", version: "0.1.2" },
packager: { name: "@ekklesia/module-pack", version: "0.2.0" },
},
incorporates: { "@ekklesia/module-sdk": "0.1.2" },
incorporates: { "@ekklesia/module-sdk": "0.2.0" },
files: listed,
};
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
@@ -3,6 +3,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
MODULE_VERIFIER_SCRATCH_ENV,
moduleVerifierScratchDirectory,
parseSha256File,
parseVerifyModulePackageArguments,
readModulePackagePlatform,
@@ -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", () => {
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"));
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "../packages/typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
"types": ["node", "vitest/globals"],
"rewriteRelativeImportExtensions": true
},
"include": [
"drizzle.config.ts",
+14 -1
View File
@@ -42,9 +42,16 @@
{$EKKLESIA_HOSTNAME} {
# Everything the API serves: the Dashboard at /, the API, display pages and their service
# worker, and the Dashboard and display host scripts. Any other path is refused here.
# worker, and the Dashboard and display host scripts.
@ekklesia path / /api/* /display/* /assets/*
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
# Any other path is refused here.
@dashboardPage path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
# Module realtime is Server-Sent Events: flush every event immediately.
@realtime path /api/v1/module-realtime/*
@@ -63,6 +70,12 @@
}
}
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle {
respond 404
}
+16
View File
@@ -51,12 +51,28 @@
{$EKKLESIA_DASHBOARD_HOSTNAME} {
@dashboard path / /assets/ekklesia-dashboard/*
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
# The API's own namespaces stay refused on this hostname.
@dashboardPage {
path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
not path /api /api/* /display /display/* /assets /assets/*
}
handle @dashboard {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle {
respond 404
}
+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/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
| [`display-kiosk.md`](display-kiosk.md) | Setting up an unattended display screen: launch, pairing, and recovery. |
## The reference deployment
@@ -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
it write the verified module directory, to a new directory outside module storage:
The backend probe executes the package's server-side code; the verifier container is its isolation
boundary. Keep the general-purpose `/tmp` mount `noexec`, and give only this one-shot verifier a
separate executable scratch tmpfs. The mount below is ephemeral, bounded at 1100 MiB (just above the
package format's 1 GiB expanded limit), `nodev`, `nosuid`, and writable only by the image's
unprivileged `node` user. `EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR` tells the CLI where that mount is;
the verifier creates a unique child for each run and removes it on success or failure.
```sh
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
docker run --rm --network none --read-only --tmpfs /tmp \
docker run --rm --network none --read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
--cap-drop ALL --security-opt no-new-privileges \
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
@@ -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`.
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
**Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with
the new `Caddyfile.example` (or `Caddyfile.split.example`). Each gains a `@dashboardPage` matcher
and `handle` block that forward every top-level path shaped like a module id — `/settings`,
`/account`, and each installed module's page such as `/signage` — to the API, which answers with
the Dashboard page where one exists and its sanitized `404` elsewhere. Without it the Dashboard still
opens at `/`, but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the
proxy after editing it. Installing or removing a module needs no proxy change.
### Rolling back
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
@@ -251,7 +271,7 @@ The same image serves both; only configuration differs.
| Hostname | Serves | Refuses (`404`) |
| --- | --- | --- |
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, `/assets/ekklesia-dashboard/*`, and every module-shaped top-level path (the Dashboard's pages, such as `/settings` and `/signage`; the API answers the rest with its `404`) | everything else, including `/api/*`, `/display/*`, and `/assets/*` |
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
@@ -327,7 +347,11 @@ networks:
Then make your proxy do what `Caddyfile.example` does:
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`,
and every path whose first segment is shaped like a module id, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$` (ADR 0057): that is
`/settings`, `/account`, and each installed module's page such as `/signage`, and the API answers
anything that is not a real page with its sanitized `404`. Without it, refreshing any Dashboard page
other than `/` is your proxy's `404`. Do not list module ids: they change as modules are installed;
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
@@ -393,7 +417,7 @@ deployment:
| # | Item | Where it is met |
| --- | --- | --- |
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*`, and every module-shaped top-level path (the Dashboard's pages) on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
@@ -500,10 +524,10 @@ finds its files:
```sh
docker build \
--file deploy/Dockerfile \
--build-arg VERSION=0.1.2 \
--build-arg VERSION=0.2.5 \
--build-arg REVISION="$(git rev-parse HEAD)" \
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
--tag ekklesia:0.1.2 \
--tag ekklesia:0.2.5 \
.
```
+4
View File
@@ -132,6 +132,10 @@ services:
command:
- mini
- -dir=/data
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
# volume size leaves modest hosts with room for only a few collections; after that, writes
# stall instead of failing. This changes allocation granularity, not object or total capacity.
- -master.volumeSizeLimitMB=32
- -s3.port.https=8443
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
- -s3.key.file=/run/ekklesia/tls/object-storage.key
+130
View File
@@ -0,0 +1,130 @@
# Setting up a display kiosk
A display is a screen — a television with a small computer, or a mini PC on a wall — that opens one
module's display page full screen and keeps it open unattended. This guide sets one up so it starts
on boot, stays paired, recovers from network and server outages on its own, and never opens a second
copy of itself.
Pairing and display identity belong to the platform (ADR 0036). Today an administrator approves a
screen in the Dashboard sidebar's **Display devices** panel; a later release moves this to
**Settings → Displays**. Nothing below changes when that happens.
## What the screen needs
- Chromium or Google Chrome, current stable. The display page is tested against Chromium.
- A **dedicated, persistent browser profile directory**. The device credential is an HttpOnly cookie
in that profile; it is what keeps the screen paired across reboots. Never use a guest, incognito, or
"clear data on exit" profile, and never share the profile with a person's browsing.
- Network access to the installation's **API hostname** (the display page, its realtime stream, and
its module requests) and to the storage hostname that serves images.
- A clock that is roughly right. Playback follows the server's clock, but TLS does not tolerate a
wildly wrong one.
## The address
Every display-capable module has one display page on the API hostname:
```text
https://<api-hostname>/display/<module-id>
```
For Signage that is `https://<api-hostname>/display/signage`. The Signage **Displays** tab shows the
exact address under **Copy setup address**. Open it on the screen itself; opening it on an
administrator's own computer only previews the display (and that browser appears in the roster as a
*Signed-in browser*, which can be removed once it is offline).
## Launching the browser
Run exactly **one** launcher, and launch the page as an app window with a fixed profile:
```bash
chromium \
--user-data-dir=/var/lib/display-kiosk/profile \
--app=https://<api-hostname>/display/signage \
--kiosk \
--no-first-run \
--noerrdialogs \
--disable-session-crashed-bubble \
--hide-crash-restore-bubble \
--disable-features=Translate \
--autoplay-policy=no-user-gesture-required \
--check-for-update-interval=31536000
```
- `--app` opens the page with no tab strip and no address bar. `--kiosk` makes it full screen.
- `--user-data-dir` is the persistent profile that holds the pairing. Keep it on persistent storage
and back it up with nothing: if it is lost, the screen simply shows a new pairing code.
- Do **not** add `--restore-last-session`, and do not also start the browser from a desktop autostart
entry. Chromium allows one process per profile; a second launch hands its URL to the running
process, which opens a **second window or tab** of the display. That is the usual cause of
"duplicate" displays. One launcher, one profile, one window.
### Start on boot and restart on exit (systemd)
Run the browser as an unprivileged user in that user's graphical session, from a single user unit:
```ini
# ~/.config/systemd/user/display-kiosk.service
[Unit]
Description=Display kiosk
After=graphical-session.target network-online.target
Wants=network-online.target
PartOf=graphical-session.target
[Service]
ExecStart=/usr/bin/chromium --user-data-dir=%h/.local/share/display-kiosk --app=https://<api-hostname>/display/signage --kiosk --no-first-run --noerrdialogs --disable-session-crashed-bubble --hide-crash-restore-bubble --disable-features=Translate --autoplay-policy=no-user-gesture-required --check-for-update-interval=31536000
Restart=always
RestartSec=5
[Install]
WantedBy=graphical-session.target
```
```bash
systemctl --user daemon-reload
systemctl --user enable --now display-kiosk.service
loginctl enable-linger <kiosk-user> # with the desktop set to log this user in automatically
```
`systemctl --user stop display-kiosk` closes the browser cleanly, which is what keeps its profile —
and the pairing cookie — intact. Avoid cutting power to a running kiosk when you can: a clean shutdown
is always safer for a browser profile.
## Pairing
1. Start the kiosk. With no credential it shows a **six-digit pairing code**.
2. In the Dashboard, enter the code under **Display devices**, give the screen a name, and approve.
3. Within a few seconds the screen collects its credential and starts showing content.
4. In Signage → **Displays**, name the display and choose its group and audience.
A paired screen stays paired across browser restarts and reboots, and for as long as it is in use:
its credential is renewed each time it checks in, and only expires after **180 days without being
used** (ADR 0056). A screen that is switched off and stored for longer than that, or that is revoked,
returns to its pairing code and is paired again the same way.
## What happens when things go wrong
| Event | What the screen does |
| --- | --- |
| Network drops for seconds | Nothing visible. The display keeps running; the realtime stream reconnects. |
| Network or API down for longer | Keeps playing what it has cached, with a small corner note; recovers by itself when the server is back. No remount, no reload. |
| API restarted or upgraded | Reconnects by itself. A new module version is picked up within five minutes and swapped in. |
| Browser crashes or is restarted | systemd restarts it; the same profile is still paired. |
| Machine reboots | Starts on boot and resumes. If the server is not up yet, the screen retries on its own (every few seconds, backing off to once a minute) until it is. |
| Display revoked in the Dashboard | Stops showing content within seconds and returns to its pairing code. |
| Display moved to another group or audience | Switches content within about one heartbeat (≈12 s), no reload. |
**Offline cold start is not supported yet.** A screen that is already showing content survives an
outage. A screen that *boots* while the server is completely unreachable can load its cached page but
cannot mount the module until the server answers, because the authorization it needs is never cached
(ADR 0043, Signage ADR 0006). It shows a short message and retries on its own until the server is
reachable — nobody needs to touch it.
## Checklist for a new kiosk
- [ ] One systemd unit, no desktop autostart entry, no second launcher.
- [ ] `--user-data-dir` on persistent storage, not shared, not cleared on exit.
- [ ] Automatic login of the kiosk user, and screen blanking/power saving disabled.
- [ ] Paired and named; assigned in Signage.
- [ ] Unplug the network for a minute: content keeps playing, then the note clears on reconnect.
- [ ] Reboot: the screen comes back paired without anyone touching it.
@@ -267,6 +267,8 @@ to follow — and the API never builds frontend source at run time.
and its only allowed build script, and a second toolchain buys HMR that a proof does not need.
- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus
module routing is a design this pass should not settle by accident.
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
> now has host-owned URL routing; module routing remains a separate, deferred decision.
- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs
to the host, and adopting it here for a two-item selector would prejudge that design.
- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
@@ -255,6 +255,10 @@ that is unremarkable, and it is measured work rather than hidden.
## Deferred
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** Client-side
> routing and deep links to a selected module, deferred below, have landed; the rest of this list is
> unchanged.
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
@@ -165,6 +165,10 @@ redirect target.
A future client router may define an internal return-location contract, but it must be a separately
validated, same-origin route decision. This pass does not create one speculatively.
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
> now has a host-owned router. It adds no return-location contract: sign-in still happens in place at
> the current URL, which is now a real page address.
### Cookie and origin policy does not broaden for the Dashboard
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
@@ -2,6 +2,7 @@
- Status: Accepted
- Date: 2026-08-24
- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md)
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
[0032](0032-native-module-realtime-transport.md),
[0033](0033-display-kiosk-host-contract.md),
@@ -151,6 +151,10 @@ reaching into the artifact or the artifact importing the host.
## Deferred
> **Cross-reference — see [ADR 0060](0060-light-dark-system-theme-and-motion.md).** A dark theme,
> System appearance, reduced motion, and a radius scale have landed; ADR 0059 added the focus-trap
> utility. The rest of this list is unchanged.
A theme editor, a brand builder, per-installation persisted overrides, a dark theme or any second
theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a
documentation site, and a focus-trap utility.
@@ -1,6 +1,6 @@
# 0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material
- Status: Proposed
- Status: Accepted
- Date: 2026-10-07
- Extends: [0049](0049-module-package-v1.md), in one respect only. ADR 0049 left exactly how licence
and notice material is collected to the packager's implementation. That implementation, and the
@@ -9,7 +9,9 @@
further source: a reviewed supplement. Everything else in ADR 0049 is unchanged, as is
`packageFormat`.
- Related: [ADR 0048](0048-generic-image-and-installed-module-storage.md),
[ADR 0051](0051-module-package-license-material.md)
[ADR 0051](0051-module-package-license-material.md),
[ADR 0055](0055-corresponding-source-artifacts.md) (decides the source-obligation policy deferred
below)
## Context
@@ -0,0 +1,255 @@
# 0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol
- Status: Accepted
- Date: 2026-10-07
- Superseded in part by: [0054](0054-public-package-reads-before-launch.md) — only where this ADR
describes the package phase before launch as private, or requires authenticated package reads.
- Extends: [0049](0049-module-package-v1.md). This is the step ADR 0049 deferred: "publishing the
Ekklesia SDK packages for module builds outside a platform workspace". A registry is used only to
build a module. Installing one stays registry-free: an installed package is self-contained, as ADR
0049 decided.
- Related: [ADR 0002](0002-pnpm-workspace-and-typescript.md),
[ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
[ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0023](0023-module-ui-build-pipeline.md),
[ADR 0039](0039-shared-design-tokens-and-elements.md),
[ADR 0047](0047-module-ci-harness.md),
[ADR 0048](0048-generic-image-and-installed-module-storage.md),
[ADR 0050](0050-module-database-abi-v1.md),
[ADR 0051](0051-module-package-license-material.md),
[ADR 0052](0052-reviewed-license-supplements.md)
## Context
Modules are independently versioned products in their own repositories (ADR 0021). Yet no module can
be built outside Ekklesia's monorepo today. Signage's continuous integration clones the platform at
a pinned commit, places Signage under `modules/signage`, and installs and builds inside that
workspace, because its Ekklesia dependencies exist only as `workspace:*` packages.
An investigation of the current packages found the following.
- **One coordinated version.** Every `@ekklesia/*` package, and the platform, is version 0.1.2. The
release process already bumps them together. The platform reports its Module SDK version, which
manifests' `compatibility.moduleSdk` ranges are checked against (ADR 0006), by reading
`@ekklesia/module-sdk`'s own package version.
- **The tarballs already stand alone.** `pnpm pack` rewrites `workspace:*` ranges to exact versions,
and each tarball carries only its built `dist`, declarations, and `LICENSE`. But every package's
`prepack` runs a command at the monorepo root, and `@ekklesia/typescript-config` is marked
`private`.
- **The SDK exposes everything.** `@ekklesia/module-sdk` re-exports the whole `@ekklesia/contracts`
barrel. That includes contracts no module needs, such as authorization and identity, and the
legacy Drizzle `ModuleDatabase` type. `@ekklesia/contracts` depends on `drizzle-orm` for that type,
so every module install pulls Drizzle in.
- Neither the module template nor Signage imports `@ekklesia/contracts` directly.
- **Frameworks are declared twice.** `@ekklesia/ui` lists `react`, and `@ekklesia/module-sdk` lists
`drizzle-orm`, as both a dependency and a peer dependency. That invites a second copy of a runtime
that must be single within one module bundle.
- **The canonical packager assumes the monorepo**, in two ways:
- it finds the module's Node.js range only through an enclosing `pnpm-workspace.yaml`;
- it decides that an Ekklesia package is the platform's own, not third-party, because its real
path does not contain `node_modules`.
An `@ekklesia/*` package installed from a registry would therefore be treated as third-party.
Packaging would omit it from `incorporates` and skip the Module SDK compatibility check.
- **The registry and its hosts.**
- The current private host is a Gitea 1.27.2 server. Its npm registry is enabled for the
organization, and that endpoint and the repositories are currently readable anonymously.
- pnpm leaves a tarball URL out of its lockfile only when the URL has the canonical
`<registry>/<name>/-/<name>-<version>.tgz` form. Gitea advertises another form, so lockfiles
produced against Gitea name the Gitea host.
- The integrity hashes in those lockfiles are independent of the host.
## Decision
### The npm registry protocol, not a provider
Ekklesia's module-development packages are distributed over the **standard npm registry protocol**,
and modules install them with ordinary pnpm or npm commands.
- **No provider is part of the architecture.** Not Gitea, GitHub, npmjs, or any other. A module
repository depends on the protocol and on a scope-to-registry mapping in normal npm/pnpm
configuration.
- **Gitea may host the private phase.** It is a host for this phase, not a permanent decision.
- **No host-neutral lockfile or Ekklesia proxy is required.** Moving to another registry may need a
one-time change of a module's registry configuration and a regeneration of its lockfile. The
integrity hashes keep verifying the package content across such a move.
A module repository's registry configuration is, for example:
```ini
# .npmrc, committed: where the @ekklesia scope lives, and nothing secret.
@ekklesia:registry=https://<registry endpoint>/
```
```ini
# User or CI configuration, never committed: the credential, from the environment.
//<registry endpoint>/:_authToken=${EKKLESIA_PACKAGES_TOKEN}
```
### The public package set
Only packages a module author needs are published. Nothing is published merely because it exists in
the workspace.
| Category | Packages |
| --- | --- |
| Author API | `@ekklesia/module-sdk`, `@ekklesia/module-database`, `@ekklesia/module-ui-sdk`, `@ekklesia/design-tokens` |
| Tooling | `@ekklesia/module-ui-build`, `@ekklesia/module-pack`, `@ekklesia/typescript-config` |
| Pre-1.0 component API | `@ekklesia/ui`: publishable, explicitly unstable before 1.0 |
| Implementation dependency only | `@ekklesia/contracts`: may be published because the packages above depend on it, but it is **not** a supported direct module-author API |
| Never published | `@ekklesia/core`, the applications, `@ekklesia/module-ui-host`, `@ekklesia/module-template`, and every other platform internal |
### A curated SDK surface
Before the first external publication, the author-facing SDK is narrowed.
- **A curated API.** `@ekklesia/module-sdk` exposes what a module author needs, and no longer
re-exports the complete `@ekklesia/contracts` barrel.
- **No forced Drizzle.** The stable SDK root does not make Drizzle a dependency of modules that do
not use it.
- **No legacy database contract.** The legacy Drizzle database contract (ADR 0016's facade type) is
not part of the standalone author-facing surface. Core still serves the legacy facade for its
deprecation window (ADR 0050). This decision only keeps its type out of what new standalone
modules build against.
- **Drizzle support lives in one place.** Drizzle-specific helpers belong to the
`@ekklesia/module-database` Drizzle adapter (ADR 0050).
### Dependency rules
These rules apply to the published packages:
- **React and ReactDOM** are peer dependencies of the public UI packages, plus development
dependencies for those packages' own builds and tests. This does not change ADR 0023: a module UI
artifact still bundles React. The peer only ensures there is one React in that bundle, the
module's own.
- **Drizzle** is an optional peer dependency only where the Drizzle adapter needs it.
- **Real implementation dependencies,** at run time or build time, remain normal dependencies.
These rules apply to a module repository:
- **Runtime and module-code packages** go in `dependencies`: the Author API packages, `@ekklesia/ui`,
React, and the module's ORM if it uses one.
- **Build tooling and configuration** go in `devDependencies`: `@ekklesia/module-ui-build`,
`@ekklesia/module-pack`, `@ekklesia/typescript-config`, and type packages.
### Versioning
- **One coordinated version during 0.x.** Every public Ekklesia module-development package uses one
release version, equal to the platform release version. The version the platform reports for
`moduleSdk` compatibility is therefore the version of the packages a module was built with.
- **No mixed trains.** Within a published train, Ekklesia-owned dependencies name the coordinated,
tested version, never a version from another train.
- **Ranges in source, exact artifacts in lockfiles.** A module may declare compatible ranges in its
source `package.json`; its lockfile pins exact artifacts with their integrity hashes.
- **`@ekklesia/module-pack` is pinned exactly.** A module repository pins it in its source
`package.json`, not only in its lockfile, because its version affects the package it produces.
- **Independent package versions are deferred.**
### Standalone packaging
- **No monorepo checkout.** A standalone module builds and packages from its own repository and its
installed dependencies.
- **Its own Node.js range.** The module declares its own Node.js compatibility, preferably in
`engines.node`, and the canonical packager reads it from there or from an explicit option. It
never requires an enclosing `pnpm-workspace.yaml`.
- **Ekklesia packages recognized by identity.** The canonical packager recognizes Ekklesia-owned
installed packages by explicit package identity and metadata, never by workspace path. So
`incorporates` and the Module SDK compatibility check (ADR 0049) work the same whether a package
came from the monorepo or from a registry. Ekklesia packages are never reported as third-party
software (ADR 0051).
- **No monorepo-root commands at install time.** Publishing workflows for the public packages run
their build in the platform's release pipeline. Nothing in a published package runs a
monorepo-root command when a consumer installs or packs it.
### Supply chain
- **Immutable versions.** A version is never republished with other content, and never withdrawn to
be reused.
- **Lockfiles.** pnpm lockfile integrity is part of the reproducibility model: modules install with a
frozen lockfile.
- **Release CI proves the tarballs before publication.** It must:
1. build and pack the public package set;
2. inspect every tarball: only the intended contents, a `LICENSE`, and no `workspace:` range;
3. install those tarballs into a clean standalone module fixture, outside any monorepo;
4. prove there that the fixture builds, packages with the canonical packager, and verifies.
Only then are the versions published.
- **Signing and provenance are deferred.**
### Registry access and the private phase
- **No secrets in module repositories.** Registry configuration for the scope is normal npm/pnpm
configuration.
- **Credentials from the environment.** Read and publish credentials come from environment or user
configuration: a read credential in module CI where reads need one, and a publish credential only
in Ekklesia's release pipeline.
- **No permanent provider.**
- **Verify access before the first publication.** Check the chosen registry's actual private and
read-access behaviour first. Ekklesia's packages must not be published unintentionally to a
registry that serves them anonymously while Ekklesia is not public.
### The release workflow
During 0.x, public package releases form a **coordinated SDK train** associated with platform
releases:
1. build;
2. pack;
3. standalone tarball smoke (see [Supply chain](#supply-chain));
4. publish immutable versions;
5. downstream module CI installs and tests those versions.
### Signage
The intended result is a normal standalone Signage repository:
- versioned `@ekklesia/*` dependencies;
- its own lockfile;
- continuous integration that installs, builds, packages, and verifies on its own.
That CI must not clone Ekklesia or place Signage under `modules/`. The migration itself is a separate
change.
## Consequences
- A module repository is an ordinary pnpm project. Clone it, install, build, and package it with the
canonical packager, with no Ekklesia checkout.
- Module authors see a smaller, deliberate API, and the platform can change its internal contracts
without breaking them.
- One coordinated version keeps compatibility simple: SDK version X is what platform version X
reports and accepts. The cost is republishing unchanged packages with each platform release.
- A registry move costs each module a configuration change and a lockfile regeneration, but no
source change. Package content stays verifiable by integrity hash throughout.
- Before the first publication, the SDK surface must be curated and the packager's two monorepo
assumptions removed. Both are breaking for code that relied on them.
- ADR 0047, still proposed, assembles a module into the platform workspace to test it. Before it is
accepted it must instead consume published packages and canonical module packages, consistent with
this decision and with ADR 0048.
## Alternatives
- **Git URL, tag, or commit dependencies.** Rejected: they tie modules to one Git host, need a build
on every install because `dist` is not committed, and cannot express version ranges.
- **Release-asset tarball URLs.** Rejected: each dependency becomes a host-specific URL, and ranges
cannot be resolved.
- **OCI artifacts for packages.** Rejected: npm and pnpm cannot install from them. (A harness image
is a different matter; see ADR 0047.)
- **Vendoring Ekklesia packages into each module repository.** Rejected: the copies drift, updates
are manual, and there would be no single canonical SDK.
- **Requiring host-neutral lockfiles, or an Ekklesia registry proxy that rewrites tarball URLs.** Not
required: a one-time lockfile regeneration on a registry move is an acceptable cost, and integrity
hashes already keep content verifiable. Ekklesia may still choose a stable endpoint later.
- **Publishing `@ekklesia/contracts` as a direct author API.** Rejected: it holds platform-wide
contracts. Modules get a curated surface through the SDK packages.
- **Externalizing React from module UI bundles to share one runtime.** Rejected again: ADR 0023's
reasoning stands, and peers only prevent duplicates within one bundle.
- **Independent versions per package now.** Deferred: during 0.x a coordinated train is simpler for
module authors and matches how the platform reports its SDK version.
## Deferred
- Provisioning a registry, and its credentials.
- Public publication on npmjs or any other public registry.
- Package signing and provenance.
- Independent package versioning.
- Dashboard and UI redesign.
- The Signage migration itself.
@@ -0,0 +1,85 @@
# 0054: Ekklesia's module-development packages are publicly readable before launch
- Status: Accepted
- Date: 2026-10-08
- Supersedes (partial): [0053](0053-standalone-module-development-packages.md) — only where it
describes the package phase before launch as private: "Gitea may host the private phase", the
read credential for module CI in "Registry access and the private phase", and its requirement
that packages not be served anonymously while Ekklesia is not public. Every other part of that ADR
remains accepted and unchanged: the npm registry protocol, the public package set and its
boundaries, the curated SDK, the dependency rules, the coordinated package train, exact release
artifacts, standalone module builds, provider portability, supply chain and immutability, and the
credential handling for publishing.
- Related: [ADR 0049](0049-module-package-v1.md)
## Context
ADR 0053 decided how Ekklesia's module-development packages (`@ekklesia/*`) are built, released, and
distributed over the npm registry protocol. It assumed that the phase before Ekklesia's public
launch would be private: module CI would carry a read credential where reads needed one, and the
first publication had to make sure the registry would not serve the packages anonymously.
Checking the chosen host before the first publication showed what keeping the packages private would
take:
- Gitea 1.27.2 decides package read access by the package **owner's** visibility, never a
repository's. The `River_of_Glory_Church` owner is a public organization, so its packages are
readable anonymously.
- That organization's repositories, Ekklesia's source among them, are already publicly readable.
- The instance allows self-registration, so "limited" visibility, readable by any signed-in user,
is readable by anyone who creates an account.
- Private packages would therefore need a separate private package owner, plus reader accounts and
read tokens for every module repository, every module's CI, and every developer.
The project owner has decided that Ekklesia is not secret. Its source is public; the project is
simply not being launched or promoted yet. Hiding the packages built from that public source would
protect nothing, and would still cost every module author an account and a token.
## Decision
1. **Ekklesia's module-development packages may be anonymously readable before the project's public
launch.** This covers the public package set of ADR 0053: search, metadata, and download.
2. **Readability is not a launch, and promises nothing more.** Public readability does not mean:
- that Ekklesia has been publicly launched;
- that the APIs are stable beyond the project's pre-1.0 SemVer policy;
- that third-party production use is supported;
- that a marketplace or public module ecosystem exists.
3. **Reading needs no authentication in this phase.** Installing `@ekklesia/*` needs no account,
reader account, read token, or authenticated install workflow. A module repository configures only
the scope's registry.
4. **Publication stays authenticated and tightly controlled.** Only Ekklesia's release process
publishes, with one dedicated publisher credential that holds nothing beyond package write
access, used only by that process, as ADR 0053's credential handling for publishing already
requires.
5. **Published versions stay immutable by Ekklesia policy.** A published version is never deleted or
republished with other bytes, whatever the registry technically allows.
6. **The current Gitea owner is an acceptable host.** The `River_of_Glory_Church` package namespace
on the current Gitea may host this phase, as long as publishing permission stays restricted to
the publisher. No private package organization is needed.
7. **A public launch is a separate decision.** Launching or promoting Ekklesia publicly, and anything
that changes for its packages then, is a product and release decision this ADR does not make.
## Consequences
- **Module authors and module CI need no Ekklesia credential.** The committed `.npmrc` scope mapping
is the whole registry configuration. ADR 0053's example of a read credential in user or CI
configuration is no longer needed in this phase.
- **ADR 0053's pre-publication access check changes meaning.** It now confirms that anonymous reads
work as intended and that publishing is refused without the publisher's permission, rather than
that reads are private. The disposable-registry suite proves both, and the publication tool can
require an anonymous download to match each published artifact.
- **Restricting publishing is now the whole access boundary**, so the publisher credential's scope,
where it is held, and who can reach it matter all the more.
- **Making the packages private again would be a new decision.** Package names, versions, and
contents already published stay public, because a published version is never deleted.
## Alternatives considered
- **A private package organization on the same Gitea.** Rejected: it hides nothing that is not
already public in the source, and it requires reader accounts and tokens for every module
repository, its CI, and every developer.
- **"Limited" package visibility.** Rejected: with self-registration open, anyone can read anyway,
while the configuration suggests otherwise.
- **Requiring sign-in for the whole instance.** Rejected: it would also hide the public
repositories.
- **Another registry for a private phase.** Unnecessary once reads are public.

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