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
208 changed files with 33478 additions and 581 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ concurrency:
env: env:
# The only registry-specific setting: where the train is published. # The only registry-specific setting: where the train is published.
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
jobs: jobs:
package-release: package-release:
+64 -15
View File
@@ -6,27 +6,60 @@ contents, because restated details drift.
## What this repository is ## What this repository is
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
monorepo: API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built
as a strictly layered pnpm/TypeScript monorepo:
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration - **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
feature module, a surface, or an app. imports a feature module, a surface, or an app.
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product - **Modules** are independently versioned product packages. First-party modules live in **their own
packages. They own their own data and migrations, declare capabilities/permissions through repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a (ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
primary and full-viewport display artifacts, authored in React against the same role-neutral example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source. `@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
Module Database ABI v1 (ADR 0050).
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every - **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
presentation concern a module does not. They consume public API/client contracts only, never Core presentation concern a module does not. The shell **hosts** module UI through the generic loader;
or module internals directly. it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only.
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
documented. Read `docs/architecture/repository-boundaries.md` and documented. Read `docs/architecture/repository-boundaries.md` and
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer. `docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
## Product direction lives beside this repository
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into
this repository to make them available. They are authoritative for product direction: read the
relevant stages and sections before designing a change. If this repository's documentation disagrees
with them, flag the conflict, correct the repository-local documentation where appropriate, and never
silently follow the stale text. Architectural decisions still change only through ADRs (below).
Rules from those documents that every change here must respect:
- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and
devices, accounts and identity, roles and authorization, branding, mail, push, storage providers,
and module installation/lifecycle — belong to the platform, and modules consume them.
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from
installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may.
Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy.
- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light,
dark, and system modes. Normal UI colours come only from the shared design tokens
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
in product UI. Destructive actions use in-product confirmation proportional to their impact.
- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard
sidebar's *Display devices* panel) and Signage's own display roster are a transitional
implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not
build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell
redesign early, and keep existing paired displays working across any change.
## ADRs are authoritative ## ADRs are authoritative
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are `docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
@@ -43,6 +76,7 @@ At minimum, read the current versions of:
- `docs/architecture/decisions/README.md` and every ADR relevant to your change - `docs/architecture/decisions/README.md` and every ADR relevant to your change
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md` - `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
- `README.md` (current platform status — what is and is not implemented) - `README.md` (current platform status — what is and is not implemented)
- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above)
- `docs/development/repository-workflow.md` and `docs/development/testing.md` - `docs/development/repository-workflow.md` and `docs/development/testing.md`
- `CONTRIBUTING.md` - `CONTRIBUTING.md`
@@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
## Working conventions ## Working conventions
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never - The canonical repositories are on the church Gitea under the `Zayin` organization:
change branch-protection settings. `ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
and branch from `origin/main`, never from whatever is checked out.
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
merge your own PR, and never change branch-protection settings.
- Never create or push a release tag. Release tags belong to the human maintainer.
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
platform logic into a module to avoid making the correct Core change.
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than - Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
burying them in an implementation. burying them in an implementation.
- Add or update tests when you change a contract or architectural rule. New behavior needs new - Add or update tests when you change a contract or architectural rule. New behavior needs new
@@ -66,6 +108,13 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
- Never run a database migration automatically (on import, on startup, in a test that isn't - Never run a database migration automatically (on import, on startup, in a test that isn't
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
an explicit, operator-invoked action. an explicit, operator-invoked action.
- Report what you find. A defect, flaky test, documentation/ADR conflict, or CI problem outside the
task at hand gets an issue in the repository it belongs to, as soon as you find it. Search for an
existing issue first; something fixed in the same PR goes in that PR's description instead. Be
descriptive: what you saw, the evidence (run and job links, commit, file and line), how to
reproduce it, the impact, and options, and never a secret. Label it `found-by-claude` (an agent
that cannot apply labels ends the body with a line saying so), leave it unassigned, and list what
you filed when you report back.
## Canonical verification commands ## Canonical verification commands
+63 -4
View File
@@ -59,7 +59,10 @@ supplied `LICENSE` bytes; dependency material remains separate in `THIRD_PARTY_N
packages may bundle a module-owned Drizzle version without coupling it to Core; packages may bundle a module-owned Drizzle version without coupling it to Core;
legacy-v8 packages retain their transitional exact-version check. Where a redistributed dependency legacy-v8 packages retain their transitional exact-version check. Where a redistributed dependency
omits licence material, the packager may add reviewed licence supplements omits licence material, the packager may add reviewed licence supplements
([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). Purge, other ([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). A native package that
redistributes LGPL or MPL code is distributed publicly only with its corresponding-source artifact,
which `ekklesia-corresponding-source` builds and gates
([ADR 0055](docs/architecture/decisions/0055-corresponding-source-artifacts.md)). Purge, other
operating-system or CPU targets, signing, and any installer or marketplace do not exist yet. operating-system or CPU targets, signing, and any installer or marketplace do not exist yet.
A module can be built in its own repository from Ekklesia's public module-development packages A module can be built in its own repository from Ekklesia's public module-development packages
@@ -819,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
public Website remain separate future designs. public Website remain separate future designs.
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested **Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing, folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing
(since added; see the Stage 1 paragraph below),
and no module-management, settings, or storage UI — module enablement and configuration are an API and and no module-management, settings, or storage UI — module enablement and configuration are an API and
a CLI, not a screen, and object storage is deployment configuration. A production CSP and a CLI, not a screen, and object storage is deployment configuration. A production CSP and
deployment for the built shells, general sidecar asset publication, React runtime sharing, the deployment for the built shells, general sidecar asset publication, React runtime sharing, the
@@ -902,6 +906,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 Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise. module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
Stage 1 begins with **real Dashboard URLs**
([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The
Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own
address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark,
and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages.
The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout
each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves
the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and
discovery refuses a module that uses one. An installed module that is disabled shows a host-owned
*This page isn't available* rather than loading anything. A module's own
sub-pages, Settings and Account pages, 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 ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
@@ -957,7 +1014,7 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ Permission registry composed
6 permissions registered 7 permissions registered
○ Module lifecycle skipped ○ Module lifecycle skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Module configuration skipped ○ Module configuration skipped
@@ -967,7 +1024,7 @@ Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
7 migrations · checksums valid 9 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0 core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180 core:0002_authorization_integrity · 634294731180
@@ -975,6 +1032,8 @@ Data definitions
core:0004_module_lifecycle_state · c8b5d8b3d48b core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b core:0005_module_configuration · c8d57f24f84b
core:0006_device_identity · 1db97b7e3ac5 core:0006_device_identity · 1db97b7e3ac5
core:0007_verified_bootstrap_owners · 49013062f3e0
core:0008_user_preferences · fa9d4b0bb9d3
PostgreSQL PostgreSQL
✓ Database TLS verified ✓ Database TLS verified
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/api", "name": "@ekklesia/api",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -28,6 +28,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@ekklesia/design-tokens": "workspace:*",
"@ekklesia/module-ui-host": "workspace:*", "@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/typescript-config": "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;
}
+53 -5
View File
@@ -8,6 +8,7 @@ import type {
AuthorizationStore, AuthorizationStore,
PermissionRegistry, PermissionRegistry,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import type { AccountStore } from "@ekklesia/core/account";
import type { DashboardStore } from "@ekklesia/core/dashboard"; import type { DashboardStore } from "@ekklesia/core/dashboard";
import type { DeviceStore } from "@ekklesia/core/devices"; import type { DeviceStore } from "@ekklesia/core/devices";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
@@ -28,10 +29,12 @@ import type { Context, MiddlewareHandler } from "hono";
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; 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 type { DashboardHostAssets } from "./dashboard-host-assets.js";
import { createDashboardHostRoutes } from "./dashboard-host-routes.js"; import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
import { createDashboardRoutes } from "./dashboard-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js";
import { createDeviceRoutes } from "./device-routes.js"; import { createDashboardSettingsRoutes } from "./dashboard-settings-routes.js";
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js"; import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js"; import { createGrantsRoutes } from "./grants-routes.js";
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js"; import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
@@ -140,6 +143,10 @@ export interface CreateApiAppOptions {
* presentation only — nothing reachable through this store influences discovery, dependency * presentation only — nothing reachable through this store influences discovery, dependency
* ordering, module startup, or any authorization decision. */ * ordering, module startup, or any authorization decision. */
readonly dashboard: DashboardStore; 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 * 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 * 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`. // own entirely. See `./health-routes.js`.
app.route("/", createHealthRoutes(options.health)); app.route("/", createHealthRoutes(options.health));
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They // The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See // page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
// `./dashboard-host-routes.js`. // `/<module-id>`. They grant no authority and resolve no principal; the page asks
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets)); // `/api/v1/principal` itself. See `./dashboard-host-routes.js`.
app.route(
"/",
createDashboardHostRoutes(options.dashboardHostAssets, {
modulePages: options.moduleLifecycle.snapshot.discoveredModules
.filter((module) => module.entrypoints.ui !== undefined)
.map((module) => module.manifest.id),
}),
);
app.route( app.route(
"/", "/",
@@ -407,6 +422,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
registry: options.authorization.registry, registry: options.authorization.registry,
runtime, runtime,
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}), ...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
...(options.devices
? {
renewDeviceCredential: (c: Context) =>
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
}
: {}),
}); });
if (options.devices) { if (options.devices) {
@@ -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/` // 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 // belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
// rather than inside it. See `./module-lifecycle-routes.js`. // 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"; 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, * 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 * 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 htmlEtag: string;
readonly script: string; readonly script: string;
readonly scriptEtag: string; readonly scriptEtag: string;
readonly appearanceScript: string;
readonly appearanceScriptEtag: string;
} }
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package /** 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 * 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. * valid page that can never render anything.
*/ */
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets { export function createDashboardHostAssets(
if (html.trim().length === 0 || script.trim().length === 0) { html: string,
script: string,
appearanceScript: string,
): DashboardHostAssets {
if (
html.trim().length === 0 ||
script.trim().length === 0 ||
appearanceScript.trim().length === 0
) {
throw new DashboardHostAssetError(); throw new DashboardHostAssetError();
} }
return Object.freeze({ return Object.freeze({
appearanceScript,
appearanceScriptEtag: strongEtag(appearanceScript),
html, html,
htmlEtag: strongEtag(html), htmlEtag: strongEtag(html),
script, script,
@@ -92,7 +106,7 @@ export function renderDashboardPage(html: string, apiOrigin: string): string {
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`); 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 * 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. */ * `/` with a 404 or a page that cannot find its API. */
export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> { 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. // rejected sibling read is left running after this function has already failed.
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8"); const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
const script = await readFile(new URL(dashboardHostAssetUrls.script), "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 { } catch {
throw new DashboardHostAssetError(); 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 { Hono } from "hono";
import { etagMatches } from "./conditional-request.js"; import { etagMatches } from "./conditional-request.js";
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js"; import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
/** /**
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API * The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed * serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never * this server and the shell's router read one list), and at each installed module's `/<module-id>`.
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under * Every one of those first segments is either a reserved platform name or a module id Core accepted,
* `/api/v1/modules/<module-id>`. * and Core refuses a module id that is a reserved name, so no module page can shadow `/api`,
* `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display
* host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still
* lands on `/` with a query (ADR 0038).
*/ */
export const DASHBOARD_HOST_PAGE_PATH = "/"; export const DASHBOARD_HOST_PAGE_PATH = "/";
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js"; export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
/** 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 * 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. */ * a composition can never pair bytes with a validator computed from different bytes. */
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets { function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
try { try {
const snapshot = createDashboardHostAssets(input.html, input.script); const snapshot = createDashboardHostAssets(input.html, input.script, input.appearanceScript);
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) { if (
snapshot.htmlEtag !== input.htmlEtag ||
snapshot.scriptEtag !== input.scriptEtag ||
snapshot.appearanceScriptEtag !== input.appearanceScriptEtag
) {
throw new DashboardHostConfigurationError(); throw new DashboardHostConfigurationError();
} }
return snapshot; 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 * Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
* default same-origin shape. * default same-origin shape.
* *
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file * Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`, * from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary * platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders * module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
* the sign-in form until `/api/v1/principal` says otherwise. * server change later) — always with the **same** bytes and the same validator, because the path
* selects a page inside the shell rather than a file here; that is what lets a refreshed or
* bookmarked deep link stay where it was.
*
* A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not
* installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does
* `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall through to the
* application's ordinary sanitized `404`. Like the display host's code, these bytes grant no
* authority: the page renders the sign-in form until `/api/v1/principal` says otherwise.
*/ */
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono { export function createDashboardHostRoutes(
assets: DashboardHostAssets,
options: DashboardHostRouteOptions,
): Hono {
const snapshot = snapshotAssets(assets); const snapshot = snapshotAssets(assets);
const modulePages = validatedModulePages(options.modulePages);
const routes = new Hono(); const routes = new Hono();
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => const page = (ifNoneMatch: string | undefined): Response =>
fixedAsset( fixedAsset(
c.req.header("if-none-match"), ifNoneMatch,
snapshot.html, snapshot.html,
snapshot.htmlEtag, snapshot.htmlEtag,
"text/html; charset=utf-8", "text/html; charset=utf-8",
DASHBOARD_PAGE_SECURITY_HEADERS, DASHBOARD_PAGE_SECURITY_HEADERS,
), );
);
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
routes.get(`/${name}`, (c) => page(c.req.header("if-none-match")));
routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match")));
}
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) => routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
fixedAsset( fixedAsset(
@@ -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; 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;
}
+3
View File
@@ -1,4 +1,5 @@
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts"; import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
import { type AccountDrizzleDatabase, createAccountDrizzleView } from "@ekklesia/core/account";
import { import {
type AuthorizationDrizzleDatabase, type AuthorizationDrizzleDatabase,
createAuthorizationDrizzleView, createAuthorizationDrizzleView,
@@ -21,6 +22,7 @@ import {
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
export interface ApiDatabaseHandle { export interface ApiDatabaseHandle {
readonly account: AccountDrizzleDatabase;
readonly authorization: AuthorizationDrizzleDatabase; readonly authorization: AuthorizationDrizzleDatabase;
readonly devices: DeviceDrizzleDatabase; readonly devices: DeviceDrizzleDatabase;
readonly dashboard: DashboardDrizzleDatabase; readonly dashboard: DashboardDrizzleDatabase;
@@ -74,6 +76,7 @@ export function createApiDatabase(
const { close, pool } = createValidatedPostgresPool(databaseUrl, options); const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
return Object.freeze({ return Object.freeze({
account: createAccountDrizzleView(pool),
authorization: createAuthorizationDrizzleView(pool), authorization: createAuthorizationDrizzleView(pool),
close, close,
devices: createDeviceDrizzleView(pool), devices: createDeviceDrizzleView(pool),
+22 -1
View File
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
*/ */
function writeDeviceCookie( function writeDeviceCookie(
c: Context, c: Context,
options: DeviceRoutesOptions, options: Pick<DeviceRoutesOptions, "secureCookies">,
name: string, name: string,
value: string, value: string,
maxAgeSeconds: number, maxAgeSeconds: number,
@@ -106,6 +106,27 @@ function writeDeviceCookie(
}); });
} }
/**
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
*
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
*/
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
if (credential === undefined || credential.length === 0) {
return;
}
writeDeviceCookie(
c,
{ secureCookies },
DEVICE_SESSION_COOKIE,
credential,
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
);
}
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
const routes = new HonoApp(); const routes = new HonoApp();
const now = options.now ?? ((): Date => new Date()); const now = options.now ?? ((): Date => new Date());
+8
View File
@@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
/** Omitted only when the registry contains no display entrypoints. Production reads the exact /** Omitted only when the registry contains no display entrypoints. Production reads the exact
* surface-owned files before binding and supplies their frozen contents here. */ * surface-owned files before binding and supplies their frozen contents here. */
readonly assets?: DisplayHostAssets; readonly assets?: DisplayHostAssets;
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
* deployment has device support; called only for a request that resolved to a device. */
readonly renewDeviceCredential?: (c: Context) => void;
} }
/** A composition error, detected before any display route is registered. Carries no module id, /** A composition error, detected before any display route is registered. Carries no module id,
@@ -199,6 +202,11 @@ function mountDisplay(
if (!viewer) { if (!viewer) {
return displayFailure(c); return displayFailure(c);
} }
// Every screen asks for this on start, on reconnect, and periodically while running, so it
// is where an in-use credential's browser expiry is pushed forward.
if (principal.kind === "device") {
options.renewDeviceCredential?.(c);
}
return c.json(await describeDisplay(options, principal, display, viewer), 200); return c.json(await describeDisplay(options, principal, display, viewer), 200);
} catch { } catch {
return displayFailure(c); return displayFailure(c);
+3
View File
@@ -6,6 +6,7 @@ import {
createAuthorizationEvaluator, createAuthorizationEvaluator,
createAuthorizationStore, createAuthorizationStore,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { createAccountStore } from "@ekklesia/core/account";
import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices"; import { createDeviceStore } from "@ekklesia/core/devices";
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host"; import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
@@ -639,6 +640,8 @@ export async function startApi(
}, },
// A typed view over the same single pool as identity, authorization, and the module facade. // A typed view over the same single pool as identity, authorization, and the module facade.
dashboard: createDashboardStore(databaseHandle.dashboard), dashboard: createDashboardStore(databaseHandle.dashboard),
// The same pool again: a person's own account summary and preferences (ADR 0062).
account: createAccountStore(databaseHandle.account),
dashboardHostAssets, dashboardHostAssets,
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup // 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. // 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");
});
});
+187 -6
View File
@@ -208,6 +208,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
} }
} }
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
const SHUTDOWN_SETTLE_MS = 3_000;
/**
* The connections left on `databaseName` once a shutdown has had a moment to land.
*
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
*/
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
for (;;) {
const count = await activeConnectionCount(databaseName);
if (count === 0 || Date.now() >= deadline) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> { async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label); const name = makeDatabaseName(label);
await createDatabase(name); await createDatabase(name);
@@ -237,6 +260,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
@@ -1349,7 +1373,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
await running.shutdown(); await running.shutdown();
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy(); await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
const remainingConnections = await activeConnectionCount(database.name); const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0); expect(remainingConnections).toBe(0);
await dropDatabase(database.name); await dropDatabase(database.name);
@@ -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", () => { describe.sequential("live API — startup and migration guarantees", () => {
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => { it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
const database = await createDisposableDatabase("unmigrated"); 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 () => { it("shuts down cleanly, closing the one shared pool", async () => {
await running.shutdown(); await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name); const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0); expect(remainingConnections).toBe(0);
await dropDatabase(database.name); await dropDatabase(database.name);
}); });
@@ -2607,10 +2787,11 @@ describe.sequential("live API — installed module permission end-to-end", () =>
expect(moduleCheck).toMatchObject({ status: "pass" }); expect(moduleCheck).toMatchObject({ status: "pass" });
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]); expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
expect(permissionsCheck).toMatchObject({ 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.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
// `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one. // `core.modules.configuration.manage`, `core.devices.manage`, `core.account.self.manage`)
details: ["7 permissions registered"], // plus the fixture module's one.
details: ["8 permissions registered"],
status: "pass", status: "pass",
}); });
// Doctor and permission composition read only the manifest — the fixture's register/start // 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 () => { it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
await running.shutdown(); await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name); const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0); expect(remainingConnections).toBe(0);
// Exactly one stop, matching the exactly-one register/start already asserted above. // Exactly one stop, matching the exactly-one register/start already asserted above.
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1); expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
+24
View File
@@ -30,6 +30,7 @@ import {
createApiApp as createApiAppWithModules, createApiApp as createApiAppWithModules,
} from "../src/app.js"; } from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -111,6 +112,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -130,6 +132,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -366,6 +369,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -391,6 +395,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -418,6 +423,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -447,6 +453,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -476,6 +483,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -502,6 +510,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -530,6 +539,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -555,6 +565,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -582,6 +593,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -616,6 +628,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -651,6 +664,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -691,6 +705,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -713,6 +728,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -737,6 +753,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -760,6 +777,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -781,6 +799,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -809,6 +828,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -840,6 +860,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -874,6 +895,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -899,6 +921,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -938,6 +961,7 @@ describe("split Dashboard and API origins (ADR 0045)", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
moduleRealtime, moduleRealtime,
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
+34 -8
View File
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({
dashboardHostAssetUrls: Object.freeze({ dashboardHostAssetUrls: Object.freeze({
html: "file:///surface/browser/index.html", html: "file:///surface/browser/index.html",
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js", 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", () => { describe("Dashboard host asset loading", () => {
beforeEach(() => readFileMock.mockReset()); 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 html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
const script = "export const dashboardHost = true;\n"; 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); const assets = await loadDashboardHostAssets(API_ORIGIN);
expect(readFileMock.mock.calls).toEqual([ expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/index.html"), "utf8"], [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/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>`; const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
expect(assets).toEqual({ expect(assets).toEqual({
appearanceScript,
appearanceScriptEtag: sha256Etag(appearanceScript),
html: rendered, html: rendered,
htmlEtag: sha256Etag(rendered), htmlEtag: sha256Etag(rendered),
script, script,
@@ -83,12 +91,27 @@ describe("Dashboard host asset loading", () => {
expect(readFileMock).toHaveBeenCalledTimes(2); expect(readFileMock).toHaveBeenCalledTimes(2);
}); });
it("rejects an empty page or script as an unavailable shell build", () => { it("fails when the appearance script is missing even though the page and script were read", async () => {
for (const [html, script] of [ readFileMock
["", "export {};"], .mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
["<html></html>", " \n"], .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) { ] 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" />', '<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
]) { ]) {
readFileMock.mockReset(); readFileMock.mockReset();
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n"); readFileMock
.mockResolvedValueOnce(html)
.mockResolvedValueOnce("export {};\n")
.mockResolvedValueOnce("(() => {})();\n");
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf( await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
DashboardHostAssetError, DashboardHostAssetError,
); );
+292 -7
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
type ModuleRouteHandler, type ModuleRouteHandler,
parseModuleManifest,
parsePermissionId, parsePermissionId,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
@@ -13,9 +14,15 @@ import {
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
type DiscoveredModule,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
isReservedModuleId,
type LegacyModuleRuntimeServices, type LegacyModuleRuntimeServices,
type ModuleDatabase, type ModuleDatabase,
type ModuleLifecycleConfiguration,
RESERVED_MODULE_IDS,
RESERVED_SETTINGS_NAMES,
RESERVED_TOP_LEVEL_NAMES,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -31,13 +38,27 @@ import {
} from "../src/dashboard-host-assets.js"; } from "../src/dashboard-host-assets.js";
import { import {
DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH,
DASHBOARD_HOST_SCRIPT_PATH, DASHBOARD_HOST_SCRIPT_PATH,
DashboardHostConfigurationError, DashboardHostConfigurationError,
createDashboardHostRoutes,
} from "../src/dashboard-host-routes.js"; } from "../src/dashboard-host-routes.js";
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js"; import { DASHBOARD_PLATFORM_PAGES, 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -54,6 +75,8 @@ function sha256Etag(text: string): string {
const PAGE_ETAG = sha256Etag(PAGE_HTML); const PAGE_ETAG = sha256Etag(PAGE_HTML);
const SCRIPT_ETAG = sha256Etag(SCRIPT); const SCRIPT_ETAG = sha256Etag(SCRIPT);
const APPEARANCE_SCRIPT = "(() => { /* pre-paint appearance */ })();\n";
const APPEARANCE_SCRIPT_ETAG = sha256Etag(APPEARANCE_SCRIPT);
function fakeRuntime(): EkklesiaIdentityRuntime { function fakeRuntime(): EkklesiaIdentityRuntime {
return { return {
@@ -78,9 +101,40 @@ function fakeModuleServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
} }
/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id
* and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */
function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: {
server: "./dist/server/index.js",
...(options.ui ? { ui: "./dist/ui/index.js" } : {}),
},
id,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
version: "0.1.0",
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: {
server: `/modules/${id}/dist/server/index.js`,
...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}),
},
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
function buildApp( function buildApp(
options: { options: {
readonly assets?: DashboardHostAssets; readonly assets?: DashboardHostAssets;
readonly configuration?: ModuleLifecycleConfiguration;
readonly discovered?: readonly DiscoveredModule[];
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[]; readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
} = {}, } = {},
) { ) {
@@ -100,11 +154,16 @@ function buildApp(
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
moduleUi: createModuleUiPublicationRegistry([]), moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT), dashboardHostAssets:
options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
health: readyHealth(), health: readyHealth(),
runtime, runtime,
trustedOrigins: [trustedOrigin], 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)", () => { describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => { it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
const { app } = buildApp(); const { app } = buildApp();
@@ -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", () => { 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(); const { app } = buildApp();
for (const path of [ for (const path of [
@@ -246,6 +518,10 @@ describe("Dashboard host boundaries", () => {
"/assets/ekklesia-dashboard/index.html", "/assets/ekklesia-dashboard/index.html",
"/assets/", "/assets/",
"/assets/ekklesia-dashboard/APP.JS", "/assets/ekklesia-dashboard/APP.JS",
"/assets/ekklesia-dashboard/appearance.js.map",
"/assets/ekklesia-dashboard/appearance.js/",
"/assets/ekklesia-dashboard/APPEARANCE.JS",
"/appearance.js",
"/dashboard", "/dashboard",
"/login", "/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", () => { 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 [ for (const assets of [
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` }, { ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
{ ...consistent, scriptEtag: PAGE_ETAG }, { ...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); expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
} }
+2
View File
@@ -26,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -140,6 +141,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(options.configuration ?? {}), dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -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);
}
});
});
@@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => {
} }
}); });
/**
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
*/
describe("credential lifetime", () => {
const ttlSeconds = 180 * 24 * 60 * 60;
const descriptor = "/api/v1/module-displays/announcements";
function renewal(response: Response): string | undefined {
return setCookieFor(response, "ekklesia_device_session");
}
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
const running = await platform("renewal");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
);
const response = await device.request(descriptor);
expect(response.status).toBe(200);
// The same credential, re-issued with a full lifetime and the same protections.
const header = renewal(response);
expect(header).toBeDefined();
expect(header).toContain(`ekklesia_device_session=${credential};`);
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
expect(header).toMatch(/HttpOnly/u);
expect(header).toMatch(/SameSite=Strict/u);
expect(header).toMatch(/Path=\//u);
expect(header).not.toMatch(/Domain=/iu);
// Using it moved the server's window forward too.
const [row] = await query<{ recent: boolean }>(
running.url,
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
);
expect(row?.recent).toBe(true);
} finally {
await running.close();
}
});
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
const running = await platform("legacy");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
// A production screen paired 179 days ago that checked in this morning: under the old
// rule it had one day left. It must carry straight on.
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
);
expect((await device.request(descriptor)).status).toBe(200);
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
);
expect((await device.request(descriptor)).status).toBe(200);
} finally {
await running.close();
}
});
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
const running = await platform("abandoned");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
);
const response = await device.request(descriptor);
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
expect(response.status).toBe(403);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
it("never renews a revoked screen", async () => {
const running = await platform("revoked-renewal");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const listed = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string }> };
await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
{},
);
const response = await device.request(descriptor);
expect(response.status).toBe(403);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
it("does not hand a device cookie to a person viewing the display", async () => {
const running = await platform("human-renewal");
try {
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
expect(response.status).toBe(200);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
});
it("keeps a human administrator's own session unaffected by a device cookie", async () => { it("keeps a human administrator's own session unaffected by a device cookie", async () => {
const running = await platform("mixed"); const running = await platform("mixed");
const device = new DeviceBrowser(running.origin); const device = new DeviceBrowser(running.origin);
+2
View File
@@ -24,6 +24,7 @@ import {
HEALTH_READY_PATH, HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS, READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.js"; } from "../src/health-routes.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
@@ -91,6 +92,7 @@ function buildApp(
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health, health,
+1 -1
View File
@@ -20,7 +20,7 @@ vi.mock("@hono/node-server", () => ({ serve: serveMock }));
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => { vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>(); const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
loadDashboardHostAssetsMock.mockImplementation(async () => loadDashboardHostAssetsMock.mockImplementation(async () =>
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"), actual.createDashboardHostAssets("<!doctype html>", "export {};\n", "(() => {})();\n"),
); );
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock }; return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
}); });
@@ -34,6 +34,7 @@ import { createApiApp } from "../src/app.js";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js"; import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -190,6 +191,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
registry: composePlatformPermissionRegistry(modules), registry: composePlatformPermissionRegistry(modules),
store: authorizationStore, store: authorizationStore,
}, },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
+26 -1
View File
@@ -195,6 +195,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
} }
} }
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
const SHUTDOWN_SETTLE_MS = 3_000;
/**
* The connections left on `databaseName` once a shutdown has had a moment to land.
*
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
*/
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
for (;;) {
const count = await activeConnectionCount(databaseName);
if (count === 0 || Date.now() >= deadline) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> { async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label); const name = makeDatabaseName(label);
await createDatabase(name); await createDatabase(name);
@@ -226,6 +249,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -256,6 +280,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
"db-facade:0000_initial", "db-facade:0000_initial",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
@@ -760,7 +785,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
await running.shutdown(); await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name); const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0); expect(remainingConnections).toBe(0);
const eventsAfterShutdown = readRecorder(); const eventsAfterShutdown = readRecorder();
@@ -37,6 +37,7 @@ import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js"; import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -199,6 +200,7 @@ async function install(options: InstallOptions): Promise<Installation> {
registry: composePlatformPermissionRegistry(modules), registry: composePlatformPermissionRegistry(modules),
store: fakeAuthorizationStore(), store: fakeAuthorizationStore(),
}, },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -30,6 +30,7 @@ import {
mergedVaryHeader, mergedVaryHeader,
} from "../src/module-http-routes.js"; } from "../src/module-http-routes.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
@@ -125,6 +126,7 @@ function buildModuleApp(options: {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -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 { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -172,6 +173,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
registry: composePlatformPermissionRegistry(modules), registry: composePlatformPermissionRegistry(modules),
store: authorizationStore, store: authorizationStore,
}, },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
+42 -1
View File
@@ -33,8 +33,10 @@ import {
createModuleUiContext, createModuleUiContext,
type EkklesiaHostConfig, type EkklesiaHostConfig,
moduleUiSourceUrl, moduleUiSourceUrl,
saveHostAccountPreferences,
} from "@ekklesia/shell"; } from "@ekklesia/shell";
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -216,6 +218,8 @@ interface HarnessOptions {
* single module proves very little. */ * single module proves very little. */
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>; readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
readonly platformOwner?: boolean; readonly platformOwner?: boolean;
/** People with an account, for the account area's routes (ADR 0062). */
readonly people?: Parameters<typeof createInMemoryAccountStore>[0];
} }
interface Harness { interface Harness {
@@ -289,6 +293,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
}, },
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
account: createInMemoryAccountStore(options.people ?? []),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -302,7 +307,10 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
if (url.pathname.startsWith("/api/v1/modules/")) { if (url.pathname.startsWith("/api/v1/modules/")) {
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`); 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; }) as unknown as typeof fetch;
return { 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 () => { it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
const harness = await startHarness(); const harness = await startHarness();
try { try {
@@ -24,6 +24,7 @@ import {
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -209,6 +210,7 @@ async function install(options: InstallOptions): Promise<Installation> {
}, },
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
+2
View File
@@ -18,6 +18,7 @@ import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/mod
import { Window } from "happy-dom"; import { Window } from "happy-dom";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -254,6 +255,7 @@ async function install(
}, },
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -9,5 +9,6 @@ export function testDashboardHostAssets(): DashboardHostAssets {
return createDashboardHostAssets( return createDashboardHostAssets(
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>', '<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
"export const dashboardHost = true;\n", "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": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"preset": "recommended" "preset": "recommended",
"suspicious": {
"noAlert": "error"
}
} }
}, },
"javascript": { "javascript": {
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
"./src/modules/lifecycle-schema.ts", "./src/modules/lifecycle-schema.ts",
"./src/modules/configuration-schema.ts", "./src/modules/configuration-schema.ts",
"./src/devices/schema.ts", "./src/devices/schema.ts",
"./src/account/schema.ts",
], ],
out: "./drizzle", out: "./drizzle",
migrations: { 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": { "destructive": {
"mode": "none" "mode": "none"
} }
},
{
"tag": "0008_user_preferences",
"destructive": {
"mode": "none"
}
} }
] ]
} }
+7
View File
@@ -57,6 +57,13 @@
"when": 1787543773058, "when": 1787543773058,
"tag": "0007_verified_bootstrap_owners", "tag": "0007_verified_bootstrap_owners",
"breakpoints": true "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", "name": "@ekklesia/core",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -12,6 +12,10 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
}, },
"./account": {
"types": "./dist/account/index.d.ts",
"import": "./dist/account/index.js"
},
"./modules": { "./modules": {
"types": "./dist/modules/index.d.ts", "types": "./dist/modules/index.d.ts",
"import": "./dist/modules/index.js" "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"); 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 * 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 * 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.", description: "Approve display device pairing, list paired devices, and revoke a device.",
id: CORE_DEVICES_MANAGE, 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 = const USAGE =
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]"; "Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
export const MODULE_VERIFIER_SCRATCH_ENV = "EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR";
export interface VerifyModulePackageArguments { export interface VerifyModulePackageArguments {
readonly archivePath: string; readonly archivePath: string;
@@ -90,6 +91,16 @@ export function parseSha256File(contents: string): string {
return match[1].toLowerCase(); return match[1].toLowerCase();
} }
/** The CLI's operator-facing bridge to Core's otherwise explicit verifier option. */
export function moduleVerifierScratchDirectory(environment: NodeJS.ProcessEnv): string | undefined {
const configured = environment[MODULE_VERIFIER_SCRATCH_ENV];
if (configured === undefined) return undefined;
if (configured === "" || !path.isAbsolute(configured)) {
throw new VerifyArgumentsError(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
}
return configured;
}
async function readJson(file: string): Promise<Record<string, unknown>> { async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>; return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
} }
@@ -121,6 +132,7 @@ export async function readModulePackagePlatform(
async function main(): Promise<void> { async function main(): Promise<void> {
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2)); const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
let expectedSha256 = parsed.expectedSha256; let expectedSha256 = parsed.expectedSha256;
if (parsed.sha256File !== undefined) { if (parsed.sha256File !== undefined) {
let contents: string; let contents: string;
@@ -137,6 +149,7 @@ async function main(): Promise<void> {
const result = await verifyModulePackage({ const result = await verifyModulePackage({
archivePath: parsed.archivePath, archivePath: parsed.archivePath,
platform, platform,
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
...(expectedSha256 === undefined ? {} : { expectedSha256 }), ...(expectedSha256 === undefined ? {} : { expectedSha256 }),
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }), ...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
}); });
+18 -8
View File
@@ -44,17 +44,26 @@ export class DeviceStoreError extends Error {
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000; export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
/** /**
* How long a device credential is accepted before the device must pair again. * How long a device credential may go **unused** before the device must pair again (ADR 0056).
* *
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight * The window slides: every request from the device moves `lastSeenAt` forward, and the host
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen * re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server * administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
* decides on every single request whether the device is still revoked, so an administrator's revoke * on every single request. The window exists only so an abandoned, forgotten screen eventually stops
* takes effect immediately regardless of how long the credential would otherwise have lived. The * being an identity at all: 180 days after it was last seen, its credential is dead.
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
*/ */
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000; export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
/** When a device credential expires: the idle window, measured from the last time the device was
* seen, or from pairing if it never has been. */
export function deviceCredentialExpiresAt(input: {
readonly createdAt: Date;
readonly lastSeenAt?: Date | null | undefined;
}): Date {
const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0);
return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS);
}
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling /** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */ * realtime and assets would otherwise turn an operational nicety into a continuous write load. */
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000; export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
@@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
} }
// The credential's own horizon, measured from when the device was paired. Revocation is // The credential's own horizon, measured from when the device was paired. Revocation is
// checked above and is what makes a long horizon acceptable. // checked above and is what makes a long horizon acceptable.
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) { // Expiry is measured from last use, not from pairing, so a screen in use never ages out.
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
return undefined; return undefined;
} }
return Object.freeze({ return Object.freeze({
+14
View File
@@ -9,6 +9,7 @@ import {
safeParseModuleManifest, safeParseModuleManifest,
semanticVersionSchema, semanticVersionSchema,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { isReservedModuleId } from "./reserved-ids.js";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json"; export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
@@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode =
| "MODULE_ENTRYPOINT_INVALID" | "MODULE_ENTRYPOINT_INVALID"
| "MODULE_ENTRYPOINT_MISSING" | "MODULE_ENTRYPOINT_MISSING"
| "MODULE_ID_DUPLICATE" | "MODULE_ID_DUPLICATE"
| "MODULE_ID_RESERVED"
| "MODULE_MANIFEST_INVALID" | "MODULE_MANIFEST_INVALID"
| "MODULE_MANIFEST_MISSING" | "MODULE_MANIFEST_MISSING"
| "MODULE_PACKAGE_INVALID" | "MODULE_PACKAGE_INVALID"
@@ -614,6 +616,18 @@ async function scanModuleDirectory(
}); });
} }
if (isReservedModuleId(manifest.id)) {
// A module's 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); const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!artifacts) { if (!artifacts) {
return undefined; return undefined;
+1
View File
@@ -13,6 +13,7 @@ export * from "./module-package-host.js";
export * from "./module-package-verification.js"; export * from "./module-package-verification.js";
export * from "./permissions.js"; export * from "./permissions.js";
export * from "./realtime.js"; export * from "./realtime.js";
export * from "./reserved-ids.js";
export * from "./runtime.js"; export * from "./runtime.js";
export * from "./schema-discovery.js"; export * from "./schema-discovery.js";
export * from "./ui-publication.js"; export * from "./ui-publication.js";
@@ -1,6 +1,6 @@
import { fork } from "node:child_process"; import { fork } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -73,7 +73,8 @@ export type ModulePackageVerificationErrorCode =
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID" | "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
| "MODULE_PACKAGE_SDK_INCOMPATIBLE" | "MODULE_PACKAGE_SDK_INCOMPATIBLE"
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE" | "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"; | "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE: MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
@@ -118,6 +119,8 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.", MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED: MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
"The package metadata contains transitional facts this platform does not understand.", "The package metadata contains transitional facts this platform does not understand.",
MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE:
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.", MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.", MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.", MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
@@ -157,6 +160,12 @@ export interface VerifyModulePackageOptions {
readonly platform: ModulePackagePlatform; readonly platform: ModulePackagePlatform;
/** A directory that must not exist yet; the verified module directory is written there. */ /** A directory that must not exist yet; the verified module directory is written there. */
readonly extractTo?: string; readonly extractTo?: string;
/**
* Existing writable directory where backend candidates may execute. Defaults to `os.tmpdir()`.
* A hardened caller whose system temporary directory is `noexec` must provide a dedicated
* executable scratch mount; each verification creates and removes its own child directory.
*/
readonly executionScratchDirectory?: string;
readonly loadTimeoutMs?: number; readonly loadTimeoutMs?: number;
} }
@@ -414,12 +423,66 @@ function probeEntrypointPath(): string {
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`); return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
} }
function decodedMountPath(value: string): string {
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
}
/** Linux exposes the effective options of every mount, including a container's tmpfs mounts. A
* verifier cannot infer `noexec` from file permissions: native addons are mapped executable rather
* than invoked as files. On systems without mountinfo, the load remains the final authority. */
async function isNoExecMount(directory: string): Promise<boolean> {
if (process.platform !== "linux") return false;
let mountinfo: string;
let canonicalDirectory: string;
try {
[mountinfo, canonicalDirectory] = await Promise.all([
readFile("/proc/self/mountinfo", "utf8"),
realpath(directory),
]);
} catch {
return false;
}
const mounts = mountinfo
.split("\n")
.map((line) => line.split(" "))
.filter((fields) => fields.length > 6 && fields.includes("-"))
.map((fields) => ({
mountPoint: decodedMountPath(fields[4] ?? ""),
options: (fields[5] ?? "").split(","),
}))
.filter(
({ mountPoint }) =>
canonicalDirectory === mountPoint ||
canonicalDirectory.startsWith(mountPoint === "/" ? "/" : `${mountPoint}/`),
)
.sort((left, right) => right.mountPoint.length - left.mountPoint.length);
return mounts[0]?.options.includes("noexec") ?? false;
}
async function createExecutionScratch(baseDirectory: string): Promise<string> {
if (!path.isAbsolute(baseDirectory)) {
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
try {
if (await isNoExecMount(baseDirectory)) {
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
return await mkdtemp(path.join(baseDirectory, "ekklesia-package-verify-"));
} catch (error) {
if (error instanceof ModulePackageVerificationError) throw error;
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
}
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports /** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
* only whether each loaded and exported a definition naming this module. */ * only whether each loaded and exported a definition naming this module. */
async function loadEntrypoints( async function loadEntrypoints(
manifest: Pick<ModuleManifest, "database" | "id" | "version">, manifest: Pick<ModuleManifest, "database" | "id" | "version">,
entrypoints: Readonly<Record<string, string>>, entrypoints: Readonly<Record<string, string>>,
timeoutMs: number, timeoutMs: number,
executionDirectory: string,
): Promise<void> { ): Promise<void> {
const outcome = await new Promise<"ok" | "failed">((resolve) => { const outcome = await new Promise<"ok" | "failed">((resolve) => {
let settled = false; let settled = false;
@@ -445,7 +508,7 @@ async function loadEntrypoints(
}), }),
], ],
{ {
cwd: tmpdir(), cwd: executionDirectory,
env: {}, env: {},
execArgv: [], execArgv: [],
serialization: "json", serialization: "json",
@@ -514,7 +577,7 @@ export async function verifyModulePackage(
const manifest = checkIdentity(archive, metadata, files); const manifest = checkIdentity(archive, metadata, files);
checkCompatibility(metadata, manifest, files, options.platform); checkCompatibility(metadata, manifest, files, options.platform);
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-")); const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
try { try {
// The candidate alone, in a scratch inventory of its own: never module storage. // The candidate alone, in a scratch inventory of its own: never module storage.
const modulesDirectory = path.join(scratch, "candidates"); const modulesDirectory = path.join(scratch, "candidates");
@@ -550,7 +613,7 @@ export async function verifyModulePackage(
// Executed only now, and first in isolation: a schema is imported in this process below only // Executed only now, and first in isolation: a schema is imported in this process below only
// after the probe proved it loads on its own. // after the probe proved it loads on its own.
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000); await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
try { try {
await discoverModuleSchemas(discoveryOptions); await discoverModuleSchemas(discoveryOptions);
} catch (error) { } catch (error) {
+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);
}
+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 { import {
CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_ACCOUNT_SELF_MANAGE,
CORE_DEVICES_MANAGE, CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE,
@@ -133,7 +134,13 @@ describe("CORE_PERMISSION_REGISTRY", () => {
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE); 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([ expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ, CORE_MODULES_UI_READ,
@@ -141,6 +148,7 @@ describe("CORE_PERMISSION_REGISTRY", () => {
CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE,
CORE_DEVICES_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.", description: "Approve display device pairing, list paired devices, and revoke a device.",
id: CORE_DEVICES_MANAGE, 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); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
for (const declaration of CORE_PERMISSION_DECLARATIONS) { for (const declaration of CORE_PERMISSION_DECLARATIONS) {
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -243,6 +244,8 @@ describe.sequential("live Core authorization store", () => {
"ekklesia_core_platform_owner_bootstrap", "ekklesia_core_platform_owner_bootstrap",
"ekklesia_core_session", "ekklesia_core_session",
"ekklesia_core_user", "ekklesia_core_user",
// A person's own preferences, one typed column each (ADR 0062).
"ekklesia_core_user_preference",
"ekklesia_core_verification", "ekklesia_core_verification",
]); ]);
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
+1 -1
View File
@@ -92,7 +92,7 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ Permission registry composed
6 permissions registered 7 permissions registered
○ Module lifecycle skipped ○ Module lifecycle skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Module configuration skipped ○ Module configuration skipped
@@ -169,13 +169,57 @@ describe("the browser shell reaches none of it", () => {
}); });
it("never puts a reset token in browser storage", async () => { 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); 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("sessionStorage");
expect(code, file.path).not.toContain("document.cookie"); 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);
} }
}); });
}); });
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
+87
View File
@@ -4,8 +4,12 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { import {
discoverModules, discoverModules,
isReservedModuleId,
ModuleDiscoveryError, ModuleDiscoveryError,
type ModuleDiscoveryIssueCode, type ModuleDiscoveryIssueCode,
RESERVED_MODULE_IDS,
RESERVED_SETTINGS_NAMES,
RESERVED_TOP_LEVEL_NAMES,
} from "../src/modules/index.js"; } from "../src/modules/index.js";
interface ModuleFixtureOptions { 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 () => { it("enforces platform and Module SDK compatibility", async () => {
const modulesDirectory = await createModulesDirectory(); const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "future", { await createModule(modulesDirectory, "future", {
@@ -182,6 +182,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
+53 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
@@ -48,6 +48,12 @@ async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
return { archivePath, sha256: fixture.sha256 }; return { archivePath, sha256: fixture.sha256 };
} }
async function executionScratch(): Promise<string> {
const directory = path.join(await scratch(), "execution");
await mkdir(directory);
return directory;
}
async function rejection( async function rejection(
options: PackageFixtureOptions, options: PackageFixtureOptions,
code: ModulePackageVerificationErrorCode, code: ModulePackageVerificationErrorCode,
@@ -100,6 +106,52 @@ describe("Module Package v1 verification (ADR 0049)", () => {
}); });
}); });
it("uses an explicit execution scratch for pure JavaScript and cleans concurrent runs", async () => {
const { archivePath } = await archiveFile();
const executionScratchDirectory = await executionScratch();
const runs = Array.from({ length: 4 }, () =>
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
);
await expect(Promise.all(runs)).resolves.toHaveLength(4);
expect(await readdir(executionScratchDirectory)).toEqual([]);
});
it("cleans its execution scratch after a backend load failure", async () => {
const { archivePath } = await archiveFile({
serverSource:
'import "dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0" };\n',
});
const executionScratchDirectory = await executionScratch();
await expect(
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
).rejects.toMatchObject({ code: "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED" });
expect(await readdir(executionScratchDirectory)).toEqual([]);
});
it("reports a missing execution scratch as a verifier environment error", async () => {
const { archivePath } = await archiveFile();
const executionScratchDirectory = path.join(await scratch(), "missing");
await expect(
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
).rejects.toMatchObject({
code: "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE",
message:
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
});
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
});
it("rejects an unsafe archive before consulting the execution scratch", async () => {
const { archivePath } = await archiveFile(
withEntry({ path: "example/../attacker-chosen", data: "x" }),
);
const executionScratchDirectory = path.join(await scratch(), "missing");
await expect(
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
).rejects.toMatchObject({ code: "MODULE_PACKAGE_UNSAFE_PATH" });
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
});
it("extracts only a verified package, with normalized modes, into a new directory", async () => { it("extracts only a verified package, with normalized modes, into a new directory", async () => {
const { archivePath } = await archiveFile(); const { archivePath } = await archiveFile();
const target = path.join(await scratch(), "example"); const target = path.join(await scratch(), "example");
+5 -2
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { import {
CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_ACCOUNT_SELF_MANAGE,
CORE_DEVICES_MANAGE, CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE,
@@ -75,8 +76,9 @@ describe("composePlatformPermissionRegistry", () => {
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true); expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true); expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true); expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
// Core's own five declarations plus one from each module. expect(registry.has(CORE_ACCOUNT_SELF_MANAGE)).toBe(true);
expect(registry.declarations).toHaveLength(8); // 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", () => { 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_LIFECYCLE_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE,
CORE_DEVICES_MANAGE, CORE_DEVICES_MANAGE,
CORE_ACCOUNT_SELF_MANAGE,
"calendar.events.read", "calendar.events.read",
"calendar.events.write", "calendar.events.write",
"people.directory.read", "people.directory.read",
+12
View File
@@ -1,5 +1,6 @@
import { import {
EMPTY_MODULE_STORAGE, EMPTY_MODULE_STORAGE,
isModuleStorageError,
isValidModuleStorageKey, isValidModuleStorageKey,
MODULE_STORAGE_KEY_MAX_BYTES, MODULE_STORAGE_KEY_MAX_BYTES,
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS, MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
} }
}); });
it("recognizes a storage error from an installed module's separate package copy", () => {
const external = Object.assign(new Error("The storage service could not be reached."), {
code: "STORAGE_PROVIDER_UNAVAILABLE",
name: "ModuleStorageError",
});
expect(external).not.toBeInstanceOf(ModuleStorageError);
expect(isModuleStorageError(external)).toBe(true);
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
});
it("gives a module no way to reach another module's storage by knowing its name", async () => { it("gives a module no way to reach another module's storage by knowing its name", async () => {
const shared = fakeProvider(); const shared = fakeProvider();
const announcements = createModuleStorage({ const announcements = createModuleStorage({
@@ -3,6 +3,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
MODULE_VERIFIER_SCRATCH_ENV,
moduleVerifierScratchDirectory,
parseSha256File, parseSha256File,
parseVerifyModulePackageArguments, parseVerifyModulePackageArguments,
readModulePackagePlatform, readModulePackagePlatform,
@@ -57,6 +59,21 @@ describe("verify-module-package digest files", () => {
}); });
}); });
describe("verify-module-package execution scratch", () => {
it("accepts an absolute operator-configured directory and otherwise leaves the library default", () => {
expect(moduleVerifierScratchDirectory({})).toBeUndefined();
expect(
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: "/run/ekklesia-verifier" }),
).toBe("/run/ekklesia-verifier");
});
it.each(["", "relative/path"])("refuses the invalid configured path %j", (configured) => {
expect(() =>
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: configured }),
).toThrow(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
});
});
describe("verify-module-package platform facts", () => { describe("verify-module-package platform facts", () => {
it("reads this platform's versions and its transitional database runtime from the repository", async () => { it("reads this platform's versions and its transitional database runtime from the repository", async () => {
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8")); const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
+14 -1
View File
@@ -42,9 +42,16 @@
{$EKKLESIA_HOSTNAME} { {$EKKLESIA_HOSTNAME} {
# Everything the API serves: the Dashboard at /, the API, display pages and their service # Everything the API serves: the Dashboard at /, the API, display pages and their service
# worker, and the Dashboard and display host scripts. Any other path is refused here. # worker, and the Dashboard and display host scripts.
@ekklesia path / /api/* /display/* /assets/* @ekklesia path / /api/* /display/* /assets/*
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
# Any other path is refused here.
@dashboardPage path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
# Module realtime is Server-Sent Events: flush every event immediately. # Module realtime is Server-Sent Events: flush every event immediately.
@realtime path /api/v1/module-realtime/* @realtime path /api/v1/module-realtime/*
@@ -63,6 +70,12 @@
} }
} }
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle { handle {
respond 404 respond 404
} }
+16
View File
@@ -51,12 +51,28 @@
{$EKKLESIA_DASHBOARD_HOSTNAME} { {$EKKLESIA_DASHBOARD_HOSTNAME} {
@dashboard path / /assets/ekklesia-dashboard/* @dashboard path / /assets/ekklesia-dashboard/*
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
# The API's own namespaces stay refused on this hostname.
@dashboardPage {
path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
not path /api /api/* /display /display/* /assets /assets/*
}
handle @dashboard { handle @dashboard {
reverse_proxy api:3000 { reverse_proxy api:3000 {
import ekklesia_forwarding import ekklesia_forwarding
} }
} }
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle { handle {
respond 404 respond 404
} }
+30 -6
View File
@@ -17,6 +17,7 @@ specific to one organization.
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. | | [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. | | [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). | | [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
| [`display-kiosk.md`](display-kiosk.md) | Setting up an unattended display screen: launch, pairing, and recovery. |
## The reference deployment ## The reference deployment
@@ -170,9 +171,20 @@ identity, target, and Node.js range against this platform, runs the platform's m
validation, and loads the backend once in a short-lived process with no environment. Only then does validation, and loads the backend once in a short-lived process with no environment. Only then does
it write the verified module directory, to a new directory outside module storage: it write the verified module directory, to a new directory outside module storage:
The backend probe executes the package's server-side code; the verifier container is its isolation
boundary. Keep the general-purpose `/tmp` mount `noexec`, and give only this one-shot verifier a
separate executable scratch tmpfs. The mount below is ephemeral, bounded at 1100 MiB (just above the
package format's 1 GiB expanded limit), `nodev`, `nosuid`, and writable only by the image's
unprivileged `node` user. `EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR` tells the CLI where that mount is;
the verifier creates a unique child for each run and removes it on success or failure.
```sh ```sh
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
docker run --rm --network none --read-only --tmpfs /tmp \ docker run --rm --network none --read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
--cap-drop ALL --security-opt no-new-privileges \
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \ -v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \ --entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \ /package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
@@ -229,6 +241,14 @@ Module storage is installation state: back it up with the database and the bucke
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`. `docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`. 5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
**Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with
the new `Caddyfile.example` (or `Caddyfile.split.example`). Each gains a `@dashboardPage` matcher
and `handle` block that forward every top-level path shaped like a module id — `/settings`,
`/account`, and each installed module's page such as `/signage` — to the API, which answers with
the Dashboard page where one exists and its sanitized `404` elsewhere. Without it the Dashboard still
opens at `/`, but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the
proxy after editing it. Installing or removing a module needs no proxy change.
### Rolling back ### Rolling back
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
@@ -251,7 +271,7 @@ The same image serves both; only configuration differs.
| Hostname | Serves | Refuses (`404`) | | Hostname | Serves | Refuses (`404`) |
| --- | --- | --- | | --- | --- | --- |
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` | | Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, `/assets/ekklesia-dashboard/*`, and every module-shaped top-level path (the Dashboard's pages, such as `/settings` and `/signage`; the API answers the rest with its `404`) | everything else, including `/api/*`, `/display/*`, and `/assets/*` |
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` | | API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | | | Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
@@ -327,7 +347,11 @@ networks:
Then make your proxy do what `Caddyfile.example` does: Then make your proxy do what `Caddyfile.example` does:
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`; - forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`,
and every path whose first segment is shaped like a module id, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$` (ADR 0057): that is
`/settings`, `/account`, and each installed module's page such as `/signage`, and the API answers
anything that is not a real page with its sanitized `404`. Without it, refreshing any Dashboard page
other than `/` is your proxy's `404`. Do not list module ids: they change as modules are installed;
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip - **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events); - stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
@@ -393,7 +417,7 @@ deployment:
| # | Item | Where it is met | | # | Item | Where it is met |
| --- | --- | --- | | --- | --- | --- |
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. | | 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*`, and every module-shaped top-level path (the Dashboard's pages) on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. | | 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. | | 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. | | 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
@@ -500,10 +524,10 @@ finds its files:
```sh ```sh
docker build \ docker build \
--file deploy/Dockerfile \ --file deploy/Dockerfile \
--build-arg VERSION=0.2.1 \ --build-arg VERSION=0.2.5 \
--build-arg REVISION="$(git rev-parse HEAD)" \ --build-arg REVISION="$(git rev-parse HEAD)" \
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \ --build-arg SOURCE=https://git.example.org/your-org/ekklesia \
--tag ekklesia:0.2.1 \ --tag ekklesia:0.2.5 \
. .
``` ```
+4
View File
@@ -132,6 +132,10 @@ services:
command: command:
- mini - mini
- -dir=/data - -dir=/data
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
# volume size leaves modest hosts with room for only a few collections; after that, writes
# stall instead of failing. This changes allocation granularity, not object or total capacity.
- -master.volumeSizeLimitMB=32
- -s3.port.https=8443 - -s3.port.https=8443
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt - -s3.cert.file=/run/ekklesia/tls/object-storage.crt
- -s3.key.file=/run/ekklesia/tls/object-storage.key - -s3.key.file=/run/ekklesia/tls/object-storage.key
+130
View File
@@ -0,0 +1,130 @@
# Setting up a display kiosk
A display is a screen — a television with a small computer, or a mini PC on a wall — that opens one
module's display page full screen and keeps it open unattended. This guide sets one up so it starts
on boot, stays paired, recovers from network and server outages on its own, and never opens a second
copy of itself.
Pairing and display identity belong to the platform (ADR 0036). Today an administrator approves a
screen in the Dashboard sidebar's **Display devices** panel; a later release moves this to
**Settings → Displays**. Nothing below changes when that happens.
## What the screen needs
- Chromium or Google Chrome, current stable. The display page is tested against Chromium.
- A **dedicated, persistent browser profile directory**. The device credential is an HttpOnly cookie
in that profile; it is what keeps the screen paired across reboots. Never use a guest, incognito, or
"clear data on exit" profile, and never share the profile with a person's browsing.
- Network access to the installation's **API hostname** (the display page, its realtime stream, and
its module requests) and to the storage hostname that serves images.
- A clock that is roughly right. Playback follows the server's clock, but TLS does not tolerate a
wildly wrong one.
## The address
Every display-capable module has one display page on the API hostname:
```text
https://<api-hostname>/display/<module-id>
```
For Signage that is `https://<api-hostname>/display/signage`. The Signage **Displays** tab shows the
exact address under **Copy setup address**. Open it on the screen itself; opening it on an
administrator's own computer only previews the display (and that browser appears in the roster as a
*Signed-in browser*, which can be removed once it is offline).
## Launching the browser
Run exactly **one** launcher, and launch the page as an app window with a fixed profile:
```bash
chromium \
--user-data-dir=/var/lib/display-kiosk/profile \
--app=https://<api-hostname>/display/signage \
--kiosk \
--no-first-run \
--noerrdialogs \
--disable-session-crashed-bubble \
--hide-crash-restore-bubble \
--disable-features=Translate \
--autoplay-policy=no-user-gesture-required \
--check-for-update-interval=31536000
```
- `--app` opens the page with no tab strip and no address bar. `--kiosk` makes it full screen.
- `--user-data-dir` is the persistent profile that holds the pairing. Keep it on persistent storage
and back it up with nothing: if it is lost, the screen simply shows a new pairing code.
- Do **not** add `--restore-last-session`, and do not also start the browser from a desktop autostart
entry. Chromium allows one process per profile; a second launch hands its URL to the running
process, which opens a **second window or tab** of the display. That is the usual cause of
"duplicate" displays. One launcher, one profile, one window.
### Start on boot and restart on exit (systemd)
Run the browser as an unprivileged user in that user's graphical session, from a single user unit:
```ini
# ~/.config/systemd/user/display-kiosk.service
[Unit]
Description=Display kiosk
After=graphical-session.target network-online.target
Wants=network-online.target
PartOf=graphical-session.target
[Service]
ExecStart=/usr/bin/chromium --user-data-dir=%h/.local/share/display-kiosk --app=https://<api-hostname>/display/signage --kiosk --no-first-run --noerrdialogs --disable-session-crashed-bubble --hide-crash-restore-bubble --disable-features=Translate --autoplay-policy=no-user-gesture-required --check-for-update-interval=31536000
Restart=always
RestartSec=5
[Install]
WantedBy=graphical-session.target
```
```bash
systemctl --user daemon-reload
systemctl --user enable --now display-kiosk.service
loginctl enable-linger <kiosk-user> # with the desktop set to log this user in automatically
```
`systemctl --user stop display-kiosk` closes the browser cleanly, which is what keeps its profile —
and the pairing cookie — intact. Avoid cutting power to a running kiosk when you can: a clean shutdown
is always safer for a browser profile.
## Pairing
1. Start the kiosk. With no credential it shows a **six-digit pairing code**.
2. In the Dashboard, enter the code under **Display devices**, give the screen a name, and approve.
3. Within a few seconds the screen collects its credential and starts showing content.
4. In Signage → **Displays**, name the display and choose its group and audience.
A paired screen stays paired across browser restarts and reboots, and for as long as it is in use:
its credential is renewed each time it checks in, and only expires after **180 days without being
used** (ADR 0056). A screen that is switched off and stored for longer than that, or that is revoked,
returns to its pairing code and is paired again the same way.
## What happens when things go wrong
| Event | What the screen does |
| --- | --- |
| Network drops for seconds | Nothing visible. The display keeps running; the realtime stream reconnects. |
| Network or API down for longer | Keeps playing what it has cached, with a small corner note; recovers by itself when the server is back. No remount, no reload. |
| API restarted or upgraded | Reconnects by itself. A new module version is picked up within five minutes and swapped in. |
| Browser crashes or is restarted | systemd restarts it; the same profile is still paired. |
| Machine reboots | Starts on boot and resumes. If the server is not up yet, the screen retries on its own (every few seconds, backing off to once a minute) until it is. |
| Display revoked in the Dashboard | Stops showing content within seconds and returns to its pairing code. |
| Display moved to another group or audience | Switches content within about one heartbeat (≈12 s), no reload. |
**Offline cold start is not supported yet.** A screen that is already showing content survives an
outage. A screen that *boots* while the server is completely unreachable can load its cached page but
cannot mount the module until the server answers, because the authorization it needs is never cached
(ADR 0043, Signage ADR 0006). It shows a short message and retries on its own until the server is
reachable — nobody needs to touch it.
## Checklist for a new kiosk
- [ ] One systemd unit, no desktop autostart entry, no second launcher.
- [ ] `--user-data-dir` on persistent storage, not shared, not cleared on exit.
- [ ] Automatic login of the kiosk user, and screen blanking/power saving disabled.
- [ ] Paired and named; assigned in Signage.
- [ ] Unplug the network for a minute: content keeps playing, then the note clears on reconnect.
- [ ] Reboot: the screen comes back paired without anyone touching it.
@@ -267,6 +267,8 @@ to follow — and the API never builds frontend source at run time.
and its only allowed build script, and a second toolchain buys HMR that a proof does not need. and its only allowed build script, and a second toolchain buys HMR that a proof does not need.
- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus - **A client router.** Rejected: a selected-module state is sufficient, and host routing versus
module routing is a design this pass should not settle by accident. module routing is a design this pass should not settle by accident.
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
> now has host-owned URL routing; module routing remains a separate, deferred decision.
- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs - **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs
to the host, and adopting it here for a two-item selector would prejudge that design. to the host, and adopting it here for a two-item selector would prejudge that design.
- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one - **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
@@ -255,6 +255,10 @@ that is unremarkable, and it is measured work rather than hidden.
## Deferred ## Deferred
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** Client-side
> routing and deep links to a selected module, deferred below, have landed; the rest of this list is
> unchanged.
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges, administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
@@ -165,6 +165,10 @@ redirect target.
A future client router may define an internal return-location contract, but it must be a separately A future client router may define an internal return-location contract, but it must be a separately
validated, same-origin route decision. This pass does not create one speculatively. validated, same-origin route decision. This pass does not create one speculatively.
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
> now has a host-owned router. It adds no return-location contract: sign-in still happens in place at
> the current URL, which is now a real page address.
### Cookie and origin policy does not broaden for the Dashboard ### Cookie and origin policy does not broaden for the Dashboard
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax, The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
@@ -2,6 +2,7 @@
- Status: Accepted - Status: Accepted
- Date: 2026-08-24 - Date: 2026-08-24
- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md)
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md), - Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
[0032](0032-native-module-realtime-transport.md), [0032](0032-native-module-realtime-transport.md),
[0033](0033-display-kiosk-host-contract.md), [0033](0033-display-kiosk-host-contract.md),
@@ -151,6 +151,10 @@ reaching into the artifact or the artifact importing the host.
## Deferred ## 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 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 theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a
documentation site, and a focus-trap utility. documentation site, and a focus-trap utility.
@@ -1,6 +1,6 @@
# 0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material # 0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material
- Status: Proposed - Status: Accepted
- Date: 2026-10-07 - Date: 2026-10-07
- Extends: [0049](0049-module-package-v1.md), in one respect only. ADR 0049 left exactly how licence - Extends: [0049](0049-module-package-v1.md), in one respect only. ADR 0049 left exactly how licence
and notice material is collected to the packager's implementation. That implementation, and the and notice material is collected to the packager's implementation. That implementation, and the
@@ -9,7 +9,9 @@
further source: a reviewed supplement. Everything else in ADR 0049 is unchanged, as is further source: a reviewed supplement. Everything else in ADR 0049 is unchanged, as is
`packageFormat`. `packageFormat`.
- Related: [ADR 0048](0048-generic-image-and-installed-module-storage.md), - Related: [ADR 0048](0048-generic-image-and-installed-module-storage.md),
[ADR 0051](0051-module-package-license-material.md) [ADR 0051](0051-module-package-license-material.md),
[ADR 0055](0055-corresponding-source-artifacts.md) (decides the source-obligation policy deferred
below)
## Context ## Context
@@ -0,0 +1,189 @@
# 0055: Native redistributable packages carry corresponding-source artifacts
- Status: Accepted
- Date: 2026-10-09
- Extends: [0049](0049-module-package-v1.md). ADR 0049 withholds distributable-release status from a
package until its licence handling is proven. This ADR adds what proving it means for a native
package that redistributes copyleft code: a sibling corresponding-source artifact, and a
fail-closed gate for public distribution. Module Package v1 is unchanged, and `packageFormat`
stays **1**.
- Decides: the item [ADR 0052](0052-reviewed-license-supplements.md) deferred, "the legal and
compliance determination of source obligations for each redistributed binary, and how a
determination is recorded", as a project policy. Reviewed supplements are unchanged in purpose
and format.
- Related: [ADR 0051](0051-module-package-license-material.md), whose rules for a module's own
licence are unchanged.
## Context
A native Module Package (ADR 0049) copies native dependencies into its `node_modules` exactly as
built. The first one, the packager's native proof with sharp, redistributes
`@img/sharp-libvips-linux-x64`. That is one shared object with about 29 libraries built into it.
Several are under the GNU LGPL, and cairo and some Rust crates are under the MPL.
ADR 0052 made the licence and notice side work. A reviewed supplement adds the licence, copyright,
and notice files the package omits, with the exact upstream source each came from. But ADR 0052 was
explicit that carrying material is not meeting obligations. The LGPL and the MPL also oblige whoever
distributes the binary to make the corresponding source available. For a library linked into a
larger work, that includes what a recipient needs to rebuild the library and use a modified version.
Recording upstream URLs does not do that: URLs move, tags are rewritten, and a mirror is somebody
else's promise.
Until this was decided, every native package containing such components was blocked from public
distribution, and the blocker had no defined way to be lifted.
## Decision
### Native packages may contain copyleft native dependencies
A native Module Package may redistribute native dependencies under the LGPL, the MPL, or a similar
licence with source obligations. It may be **distributed publicly** only when it is released
together with a **corresponding-source artifact** that meets this ADR. A package whose artifact
cannot be produced and verified is not approved for public distribution.
This is Ekklesia's **project compliance policy**: a conservative rule the project holds itself to.
It is not legal advice, and it is not legal certification. Repository review of the artifact, its
inputs, and its instructions is project and code review. It does not replace professional legal
review, and nothing built from this ADR may claim that it does.
### Supplements and source artifacts do different jobs
- **A reviewed supplement (ADR 0052)** identifies and verifies the licence and notice material for a
redistributed binary. It travels inside the package, in `THIRD_PARTY_NOTICES`.
- **A corresponding-source artifact** carries the source, patches, build definitions, and rebuild
and relink instructions for the covered native components. It travels beside the package, as a
separate release artifact.
A supplement alone never satisfies the source-availability policy. A source artifact never replaces
the supplement or `THIRD_PARTY_NOTICES`.
### What the artifact contains
For each covered native dependency the package redistributes, the artifact contains:
- the exact source the binary was built from, as bytes, not as references:
- its build definitions;
- the source archive of every library compiled into it;
- every patch the build applied;
- every source package a language package manager resolved for the build;
- the source of any runtime library the toolchain linked into it;
- the licence and copyright material: the reviewed supplement, and the files inside the sources;
- **instructions** that are technically actionable. They name what the artifact corresponds to and
its target, and say how to apply the patches and rebuild the covered library. They also say how a
rebuilt or modified library replaces the redistributed one in the installed module, and which
toolchain and configuration to use, or point to the included build definitions;
- a deterministic, machine-readable manifest of every component and file, each with its digest,
plus the human-readable provenance.
Components that are not part of the binary are left out, each with its reason recorded: build-only,
test-only, or another platform's. The exception is a build-time input needed to rebuild the covered
library, such as a source package a build script requires. That is included and marked as
build-only. Host system libraries and general-purpose build tools are not redistributed, and the
instructions name the versions they need.
### It is tied to exact inputs, and verified before it is written
- **Bound to the binary.** An artifact names each covered dependency by package name, version, and
the content digest a supplement is matched by (ADR 0052). It names the module identity and target
of the package it accompanies. An artifact for one version of a binary never satisfies another.
- **From reviewed inputs only.** The inputs come from a checked-in, reviewed **source recipe** per
covered dependency version. The recipe records each component's name, version, origin,
relationship to the binary, and licence material, and each source file's and patch's size and
SHA-256.
- **Acquisition is separate from construction.** Obtaining the recorded bytes may use the network,
in an explicit preparation step that stores them in a local cache keyed by content digest.
Constructing the artifact is offline. It reads only the cache and checked-in files, verifies every
byte against the recipe before writing anything, and never fetches or trusts a current upstream
response.
- **Deterministic.** The same inputs produce the same artifact, byte for byte, in any working
directory: normalized relative paths, fixed ownership, modes, and timestamps, sorted entries, and
no machine-specific path.
### Binary and source are linked by recorded digests
A public release of a native package with covered components consists of sibling artifacts:
- the Module Package archive and its SHA-256 (ADR 0049);
- the corresponding-source archive and its SHA-256;
- machine-readable **redistribution metadata** recording:
- the package archive's name and SHA-256;
- the module identity and target;
- each covered dependency's name, version, licence, and content digest;
- the supplement and recipe it was assembled from;
- the source archive's name and SHA-256, and the SHA-256 of its manifest;
- a **release notice**, displayed alongside the binary wherever it is offered. It says, in
substance, that the release includes third-party software under licences including the GNU LGPL
or the MPL, and that corresponding source and build and relink materials for the covered native
dependencies are in the accompanying artifact, named by its exact file name and SHA-256.
A reviewer can therefore tell exactly which source artifact corresponds to which redistributed
binary, from digests, without guessing from file names. The source archive is never placed inside
the Module Package. `THIRD_PARTY_NOTICES` stays as it is, and never duplicates the source.
### Public distribution fails closed
Building and testing packages stays unrestricted: the packager produces native packages exactly as
before, for development, CI, and private use. **Public redistribution approval** is a separate gate.
For a package intended for public distribution, the gate requires that:
- the package's native dependency inventory is known;
- every covered dependency has its reviewed supplement and source recipe;
- the corresponding-source artifact is present;
- every digest matches;
- the artifact's manifest validates against the recipe and the archive's own contents;
- the release notice names the exact artifact.
If any requirement fails, approval fails. Bundled (not copied) code under such a licence is not
handled by this mechanism, so the gate refuses it rather than approving it.
### Retention, and no rewriting history
- Ekklesia keeps a release's corresponding-source artifact for as long as it distributes, or
supports access to, the matching binary release. Nothing deletes one automatically while that
binary remains available.
- The bytes of a source artifact for an existing binary release are never replaced under the same
identity.
- A correction is a new release, or an explicitly versioned compliance artifact that the
redistribution metadata names. History is never silently replaced.
### What this ADR does not do
- It does not change any module's own licence, or ADR 0051's rules for it. The module's own source
obligations are met by the module's own repository, as before.
- It does not make **runtime downloading** an acceptable workaround. Fetching a copyleft library at
run time instead of redistributing it moves the obligation rather than meeting it, makes
installations depend on a third party, and breaks ADR 0048's self-contained installed modules. It
is not the preferred path.
- It does not change Module Package v1, the verifier's acceptance of a package, or the content of
existing supplements.
## Consequences
- Native modules that need copyleft native libraries, starting with sharp's libvips, can be
distributed publicly under a defined, checkable policy, instead of being blocked indefinitely.
- Each covered dependency version needs a reviewed source recipe, as it needs a reviewed supplement.
A dependency bump needs both before public distribution works again.
- Releases carry an artifact many times larger than the package itself (about 200 MB for libvips
1.3.4, against a 9 MB package), and Ekklesia commits to retaining it.
- Constructing the artifact needs the verified source cache. Acquisition happens once per recipe,
not on every build.
## Alternatives
- **Treat supplements, source URLs, or a written offer as sufficient.** Rejected: URLs and offers
depend on third parties and on time. This policy requires the source itself, retained with the
release.
- **Put the source inside the Module Package.** Rejected: installations would store hundreds of
megabytes they never need, and the package format would change for a distribution concern.
- **Download sources while constructing the artifact.** Rejected: a release would depend on mutable
remote responses. Acquisition is explicit and verified, and construction is offline.
- **Download native libraries at run time.** Rejected, as above.
- **Per-dependency special cases in code.** Rejected for the same reason as in ADR 0052: recipes are
reviewed data, and the mechanism knows no particular library.
## Deferred
- Hosting and serving source artifacts for published module releases, and the release CI that runs
the gate and keeps the verified source cache.
- Corresponding source for bundled copyleft code, if a module ever needs it.
- Recipes provided by module repositories, alongside the question ADR 0052 deferred for supplements.
@@ -0,0 +1,76 @@
# 0056: A device credential lasts while the device is in use
- Status: Accepted
- Date: 2026-10-12
- Supersedes: the "Session lifetime" section of [0036](0036-managed-device-identity-and-pairing.md).
Everything else in ADR 0036 — the device as its own principal, pairing, revocation on every
request, re-pairing as a new identity — is unchanged.
## Context
ADR 0036 gave a device credential a fixed 180 days from pairing. The intent was that a screen on a
ceiling should not need re-pairing on a short cycle, and that an abandoned screen should eventually
stop being an identity.
A fixed horizon does the second and fails the first. Every screen in the building expires on the
anniversary of its pairing whether or not it was in use the day before, and drops to a pairing code
in front of the congregation until someone with Dashboard access walks to it. For screens paired
together, that is every screen at once. Nothing about a working, unrevoked screen is less trustworthy
on day 181 than on day 179; only the calendar changed.
## Decision
**The 180 days are an idle window, not a lifetime.** A device credential is accepted while the
device was last seen less than 180 days ago (or, if it has never been seen, was paired less than 180
days ago). `lastSeenAt` already moves forward on authenticated device requests (throttled to one
write per 15 minutes), so the server-side window slides with use and needs no new column or
migration.
**The browser's copy is renewed by the host, not rotated.** The display descriptor —
`GET /api/v1/module-displays/<module>`, which every display requests when it starts, when it
reconnects, and periodically while it runs — re-issues the device cookie that authenticated the
request with a fresh 180-day `Max-Age` and the same attributes as at pairing (`HttpOnly`,
`SameSite=Strict`, host-only, `Path=/`, `Secure` on https). The value is unchanged.
Renewal happens only for a request that already resolved to a live, unrevoked device. A revoked,
expired, or unknown credential is anonymous, as before, and is never renewed; a person viewing a
display never receives a device cookie.
**Revocation remains the boundary.** It is still checked on every request, and an administrator's
revoke still takes effect immediately.
## Why not rotate
Rotation (issue a new value, invalidate the old) would bound how long any one stolen value works and
could reveal a cloned credential. For an unattended screen it trades that for a new way to strand
the display: a renewal response lost to a network drop, a power cut, or a browser that never
persisted the new cookie leaves the screen holding a dead credential and showing a pairing code,
which is the failure this decision exists to remove. A refresh-token scheme with overlap windows
could avoid that, at the cost of real complexity in the one place mistakes are invisible until a
wall goes dark. That is not justified while revocation is immediate and a device principal can only
read the displays it was paired for.
## Consequences
- A screen that is used at least once every 180 days stays paired until an administrator revokes
it. An abandoned screen still stops being an identity 180 days after it was last seen.
- Existing credentials need no migration. A screen paired under the fixed rule is renewed the next
time it requests its descriptor, provided its browser still holds the cookie.
- A stolen device credential stays usable for as long as anyone keeps using it, until it is revoked.
Under ADR 0036 it was usable for at most 180 days from pairing. The exposure is bounded by what a
device may do — the display permissions of the module it was paired for, never a person's access
— and by revocation. Operators can see each device's last-seen time and revoke anything
unexpected.
- A credential is not bound to a browser or network. Like a human session cookie, whoever presents
it is the device.
## Alternatives
- **Keep the fixed 180 days.** Rejected: every in-use screen fails on a schedule, and screens paired
together fail together.
- **Never expire.** Rejected: an abandoned or forgotten screen would remain an identity forever, and
its credential would never stop working without someone remembering to revoke it.
- **Rotate on renewal.** Rejected for now; see above.
- **Renew on every authenticated response.** Rejected: principal resolution happens deep in shared
middleware that never writes responses. One endpoint every display already requests periodically
is enough, and keeps the behaviour in one visible place.
@@ -0,0 +1,241 @@
# 0057: Dashboard URLs are real, host-owned routes
- Status: Accepted
- Date: 2026-10-14
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
[0027](0027-dashboard-navigation-and-flat-modules.md),
[0034](0034-host-owned-browser-authentication.md),
[0044](0044-self-hosted-reference-deployment.md),
[0045](0045-split-dashboard-and-api-origins.md)
## Context
The Dashboard had exactly one URL. `apps/api` served the page at `/` and nowhere else, both
reference proxies forwarded only `/` to it, and the shell kept "which module is open" in React state.
Opening a module never changed the address bar, so a refresh always returned to the first module,
nothing could be bookmarked or shared, and the browser's Back button left the Dashboard entirely.
ADR 0026 rejected a client router *for that pass* — "a selected-module state is sufficient" — and
ADR 0027 deferred client routing and deep links.
The product contract now requires them. DESIGN.md §2.7 says the URL represents the current place in
the product: a refresh returns to the same page, deep links work, Back and Forward behave
predictably. §5 makes an **Overview** the Dashboard's landing page, and §12.6 says a disabled module's
routes stop behaving as active routes. Everything else Stage 1 builds — the sidebar, Settings, the
account area, platform Displays, a module's own pages in the sidebar, and returning to the workflow a
Settings deep link came from — needs stable addresses to hang from, so this is the first slice.
A route that works in the shell but not on refresh is worse than no route, because it looks like it
works. Refresh is a server and proxy concern before it is a browser one: the API had to serve the
page at the new paths, and the proxies had to forward them.
## Decision
### One top-level URL space, shared by platform pages and module pages
```text
/ the Overview
/settings[/…] Settings
/account[/…] the account area
/<module-id> a module's page: /signage, /calendar, /people
/<module-id>/<page>[/…] reserved for that module's own pages: /signage/timelines
```
A module is a first-class top-level destination, addressed by its id, exactly as Settings and the
Account area are. "Module" is an implementation concept — how the platform packages a product — and
it does not appear in the addresses people see, bookmark, and share. A module id is lowercase
kebab-case and Core-validated, which is what makes it usable as a path segment with no further
encoding.
### Platform-owned top-level names are reserved, and Core enforces it
Sharing one namespace means a module must never be able to take a name the platform uses.
`RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`) is the complete set the platform serves
at the top level **today**, taken from what is actually registered rather than from guesses:
| Name | Owned by |
| --- | --- |
| `api` | the API: identity, principal, health, Core resources, module routes, module UI artifacts, display descriptors, realtime |
| `assets` | the Dashboard's and display host's scripts |
| `display` | the display host's pages and service worker |
| `settings` | the Dashboard's Settings pages |
| `account` | the Dashboard's account area |
Discovery refuses a module that declares one (`MODULE_ID_RESERVED`), so the check covers API
startup, `pnpm modules:validate`, and Module Package verification — before a module is installed,
not only when it is served. Exact names only: `settings-sync` or `displays` are ordinary ids.
The set is held to reality by tests in `apps/api` rather than by review: every top-level segment the
composed API registers is a reserved name or a module page, the display host's and Dashboard
script's path constants all start with a reserved name, and every Dashboard page the shell
interprets is reserved. Adding a new top-level platform route therefore fails a test until its name
is reserved. Reserving a name later is a **breaking change** for any installation with a module of
that id, so the set grows only deliberately, and only for a real route.
The rule lives in Core's discovery rather than in the module manifest grammar
(`@ekklesia/contracts`), because it is the Ekklesia host's URL space, not a property of a module: a
standalone host (ADR 0021) has no Settings page to protect. A module built against the current
public packages is unaffected unless it uses one of these five names.
### The server serves one page, unchanged, at every real Dashboard address — and nowhere else
`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at `/settings` and
`/account` and everything below each (`DASHBOARD_PLATFORM_PAGES`, exported by the shell as
`@ekklesia/shell/dashboard-routes`, so the server and the shell's router read one list), and at
`/<module-id>` and everything below it for **each module this process discovered that declares a
Dashboard UI**. Every answer is the same frozen bytes, the same strong `ETag`, and the same security
headers as `/`: the path selects a page inside the shell, never a file on the server. Nothing from
the request is composed into a lookup, no directory is exposed, and the script and source map remain
reachable only at their own literal paths.
The module set is the boot snapshot's discovered inventory, fixed at startup like every other
module-derived route, and it deliberately **includes disabled and blocked modules**. A bookmark to a
module an administrator turned off still opens the Dashboard, which says the module is unavailable;
that is more useful than an address that suddenly looks like it never existed. A module that is not
installed, or that has no Dashboard UI (a display-only module, ADR 0033), has no page.
Everything else stays the sanitized `404`: an arbitrary root path (`/definitely-not-a-real-module`),
a look-alike (`/settings-old`, `/signages`), another method, `/index.html`, and so on. The routes
also refuse to compose if handed a module page that is not a valid module id, is a reserved name, or
appears twice — discovery already guarantees all three, and the routes do not trust it.
Serving `/<module-id>` before sign-in means an anonymous request can learn whether a module with a
Dashboard UI is installed, by its `200` or `404`. That is accepted: it is the same disclosure the
display host already makes at `/display/<module-id>` (ADR 0033), a module id names a product rather
than any data, and the alternative — a page at every module-shaped root path — is exactly the
"arbitrary path becomes a Dashboard page" this decision rejects. The authenticated module inventory
and every module route keep their authorization unchanged.
### The proxies forward module-shaped names and let the API decide
A proxy cannot know which modules an installation has, and a proxy configuration listing them would
have to be edited on every install. Both reference Caddyfiles therefore forward every top-level path
whose first segment has a module id's shape, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$`, and the API
answers it with the page or its sanitized `404`. Anything not shaped like one — `/favicon.ico`,
`/.well-known/…`, an uppercase name — is still refused by the proxy without reaching the API. In the
split topology (ADR 0045) the Dashboard hostname forwards module-shaped names but still refuses
`/api`, `/display`, and `/assets`, and the API hostname still serves no Dashboard page. The
development server falls back to the page for any path with no file.
### The shell's router is host-owned, small, and deliberately not a library
The address bar is the source of truth for which page is open. The shell reads it through a
four-method `HostHistory` (`pathname`, `push`, `replace`, `subscribe`); the browser implementation is
built in the entry point from `window` and handed down, so the entry point remains the only file in
the package that touches a browser global, and every routing behaviour is tested against an in-memory
history whose entries a test can inspect. No state object is ever stored in a history entry — the path
alone is meaningful — so nothing put into history can be read back as authority.
`react-router-dom` is not adopted. Modules are refused it because routing belongs to the host
(ADR 0022, `pnpm boundaries`); the shell's own pages are a handful of host screens plus one mounted
module; and adopting a library now would prejudge how a module's own pages are addressed, which is
a separate decision (see **Deferred**).
### What each path shows
- `/` is the **Overview**. The shell no longer opens the first module for the person: the Overview is
the landing page. Until modules can contribute cards to it, it says plainly that it has nothing to
show yet.
- `/<module-id>` mounts the module whose navigation entry has that id, through the unchanged generic
loader and the unchanged keyed frame, so a stale load still cannot reach a newer page. The server's
navigation is the only authority on what is mountable. An id it does not list — a disabled or
blocked module whose address the server still serves — shows *This page isn't available* and loads
nothing; the page never echoes the address back. This is what makes a disabled module's routes stop
behaving as active routes, with no new server state.
- `/settings…` and `/account…` are the platform's own pages and are never read as module pages. They
have no content yet, so they show *Page not found* for now.
- Everything below `/<module-id>/` belongs to that module's own pages. It is served by the API today
and shows *Page not found* in the shell until the module page contract exists. The shell never
guesses, and never shows a module's first page for an address the module never defined.
- One trailing slash is tolerated and corrected to the canonical spelling in place (`replace`, no new
entry), so what is bookmarked next is the one canonical address.
### Navigation is real links
Every navigation item is an `<a href>` with its canonical address and `aria-current="page"` when it
is open, marked by weight as well as colour. An ordinary click opens the page in place and adds one
history entry; a click with a modifier key or the middle button is left to the browser, so a page can
be opened in a new tab. Clicking the open page's own link adds no entry. The browser's Back and Forward
walk exactly the pages that were open.
### Page changes behave like page loads for assistive technology
After an in-place page change the shell moves focus to the page's `<main>` (named after the page) as
a full load would have; the first page shown after a refresh or sign-in is left alone. The entry point
names the browser tab after the open page ("Overview", the module's display name, or the error page's
title, and "Sign in" while signed out) — never "Ekklesia", in keeping with ADR 0046. The static page's
own `<title>` is unchanged here; it belongs to the branding work.
### Sign-in is unchanged, and still has no return URL
ADR 0034 holds exactly. The sign-in form still replaces the application in place at the current URL,
so a deep link opened while signed out lands on its own page once the person signs in — with no
`return`, `next`, or callback value, no redirect, and no script-driven navigation off the
Dashboard's own paths. The shell only ever moves between its own canonical paths. Emailed
verification and reset links still land on `/` with a query (ADR 0038).
## Alternatives
- **Module pages under a prefix (`/modules/<id>`).** Rejected: it exposes an implementation concept
in every address people see, and makes a module a second-class destination beside Settings. The
collision it avoided is handled instead by reserving the platform's names in Core.
- **Hash routing (`/#/signage`).** Rejected: every address is one page to the server, the proxy, and
any analytics; fragments never reach the server, so no server-side decision about a path is
possible; and DESIGN.md asks for real URLs.
- **Serving the page at any module-shaped root path.** Rejected: `/definitely-not-a-real-module`
would become a Dashboard page, and the server would answer `200` for every mistyped address. It
would remove the installed-module disclosure described above, at the cost of the server no longer
meaning anything by a `200`.
- **Serving only enabled, running modules' pages.** Rejected: a bookmark to a module an
administrator disabled would become a bare `404`, indistinguishable from a typo, instead of the
Dashboard explaining that the module is unavailable.
- **Listing modules in the proxy configuration.** Rejected: every install would need a proxy edit.
- **Reserving more names now** (`login`, `help`, `overview`, …) "just in case". Rejected: a reserved
name refuses a real module, so a name is reserved when the platform gets a route there and not
before.
- **Putting the reserved names in the manifest grammar.** Rejected for now: it is the Ekklesia host's
URL space, not a module property, and would need a public-contract release for no extra safety,
since discovery already refuses the module before it can be installed.
- **`react-router-dom` or another routing library.** Rejected for now; see above.
- **Keeping "open the first module" at `/`.** Rejected: DESIGN.md makes the Overview the landing page,
and an address that silently means "whatever happens to be first" is not a stable address.
## Consequences
- A refresh, a bookmark, and a shared link land on the page that was open; Back and Forward walk the
Dashboard's own pages. Proven at three levels: the shell against an in-memory history; `apps/api`
serving the page at platform pages and installed modules' pages and the sanitized `404` everywhere
else; and the deploy smoke test, through the real proxy, checking platform deep links, unknown
names, a module's address staying served while the module is disabled and disappearing and
returning as it is removed and reinstalled,
and reloading a module's address in a real browser and requiring the same module to mount again.
- **The Dashboard opens on the Overview, not on the first module.** A person who used to land in
Signage now chooses it once from the navigation, and can bookmark `/signage`.
- **A module may not be called `account`, `api`, `assets`, `display`, or `settings`.** No known module
is; one that was would now fail discovery with `MODULE_ID_RESERVED` and must be renamed.
- **Operators with their own proxy must forward module-shaped top-level paths** (the matcher above),
or a refresh on any page other than `/` is the proxy's `404`. The deployment guide says so.
- `@ekklesia/shell` gains a `./dashboard-routes` subpath, and `apps/api` reads it. The surface remains
private; this is the same relationship `./dashboard-assets` already has.
- The navigation landmark is labelled "Dashboard" rather than "Modules", because it now contains the
Overview as well as modules.
## Deferred
> **Cross-reference — see
> [ADR 0061](0061-settings-hierarchy-and-module-settings-routes.md).** The Settings pages and their
> addresses have since been defined; the rest of this list is unchanged.
Each of these needs its own decision. This ADR reserves the addresses for them and decides none of
them:
- **A module's own pages** — how a module declares the pages that appear under it in the sidebar
(`/signage/playback`, `/signage/timelines`, `/signage/displays`), how it learns and changes its
current sub-path through the module UI SDK, and what an address below `/<module-id>/` means. The
server already serves the page there and the proxies already forward it, so that contract is a
manifest, SDK, and shell change with no further routing or deployment change. It is its own ADR.
- **Return to origin** — how a module or a Settings page links into another part of the Dashboard and
comes back to the exact workflow that sent the person there (DESIGN.md §2.7, §4.8). It must remain
a validated, same-origin route decision, as ADR 0034 requires, and is designed with the module page
contract rather than here.
- The Settings and Account pages themselves, the sidebar redesign, the mobile drawer, Overview cards,
breadcrumbs, and the installation's name in the browser tab.
@@ -0,0 +1,192 @@
# 0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary
- Status: Accepted
- Date: 2026-10-14
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
[0027](0027-dashboard-navigation-and-flat-modules.md),
[0039](0039-shared-design-tokens-and-elements.md),
[0057](0057-dashboard-urls-are-host-owned-routes.md)
## Context
ADR 0057 gave every Dashboard page a real address, but the shell around those pages was still the
first proof's layout: a top header holding a hard-coded product name and a Sign out button, a fixed
`15rem` column of text buttons, and the transitional device panel above the navigation. It had no
icons, could not collapse, had no account area, and on a phone it was the same two columns squeezed
until the page was unusable.
DESIGN.md §4 fixes what the shell is: a collapsible left sidebar — icons and labels expanded, icons
only collapsed, with accessible names — church identity at the top, the account area anchored at the
bottom, no persistent top bar, and on phones a drawer with the **same** hierarchy (locked decisions
4–8). It left one thing open that this work cannot proceed without: **which icon library**
(§27, "Final free icon library selection"). The maintainer has decided it: Lucide.
## Decision
### Lucide is Ekklesia's icon vocabulary
Lucide (`lucide-react`, ISC-licensed, no runtime dependencies) is the one icon set for first-party
Ekklesia UI: the shell, `@ekklesia/ui` if it ever draws an icon, and first-party modules. Where
Lucide has an appropriate icon, nobody draws their own; a meaning that Lucide genuinely cannot
express is the only reason to design one, and that is a decision to record, not a shortcut.
Icons are **decorative**. Every control that shows one also has a text name — visible, or visually
hidden when the sidebar is collapsed — and every icon is `aria-hidden`. An icon never carries
meaning alone, and its stroke is `currentColor`, so it takes its colour from the design tokens
exactly as text does (ADR 0039). Icons are imported by name, so a bundle contains exactly the icons
it uses.
The shell imports every icon it uses in one file (`surfaces/shell/src/browser/shell-icons.tsx`),
keyed by meaning — `overview`, `module`, `account`, `signOut`, … — so the vocabulary is reviewable
and an icon is chosen once per meaning rather than per call site. `lucide-react` is a dependency of
`@ekklesia/shell` only, pinned exactly (`1.48.0`); no public `@ekklesia/*` package gains it here.
A module that needs icons in its own UI uses `lucide-react` itself, which `pnpm boundaries` already
permits (module `.svg` presentation assets remain rejected).
### Modules have no icon of their own yet
Every module in the navigation is drawn with **one generic module icon** (Lucide `Blocks`). There is
no table mapping known module ids to icons: that would be a second, host-owned answer to a question
the module owns, and every new module would wait on a shell release to look right. A module will
declare its own icon — by Lucide name — through the module navigation contract that also brings its
sub-pages (manifest v9). Nothing in the manifest changes here.
### The shell's layout
On a wide screen (at or above `48em`, which is 768px at the default text size and follows a
person's own text-size setting), the Dashboard is a sidebar and the page:
```text
┌────────────┬──────────────────────────────┐
│ identity ◂│ │
│ Overview │ │
│ GROUP │ the page │
│ Module │ │
│ Module │ │
│ (devices) │ │
│────────────│ │
│ Your account │
└────────────┴──────────────────────────────┘
```
- **Top:** the identity slot and the collapse toggle. The slot is a neutral placeholder (a plain mark
and the product name) and deliberately takes **no inputs**: the branding work (issue #101) owns
what goes there and where it comes from, and replaces this one component rather than undoing a
configuration model invented early. It is not a heading; each page's own title is.
- **Middle:** the navigation, scrolling on its own — the Overview first, then the server's groups and
modules in the server's order (ADR 0027). The shell sorts and filters nothing; a disabled module is
absent because the navigation endpoint does not list it (ADR 0028), and its address still shows
*This page isn't available* (ADR 0057). Below it, while expanded, the transitional device panel
(ADR 0036) until Settings → Displays replaces it.
- **Bottom:** the account control, a product-owned menu button (below).
**Collapsed**, the sidebar is icons only (`4.5rem` rather than `16rem`). Each item keeps its text in
the DOM, visually hidden, as its accessible name, and shows a tooltip on hover **and on keyboard
focus**. The tooltip is a visual duplicate of that name, so it is `aria-hidden` and nothing is
announced twice; it meets WCAG 2.2 §1.4.13 — dismissible with Escape without moving focus, hoverable,
and persistent until focus or the pointer leaves — and is positioned beside the item so a scrolling
sidebar cannot clip it. Group labels stay in the DOM for assistive technology, with a divider
standing in for them visually. The toggle states what it does ("Collapse navigation" / "Expand
navigation") and carries `aria-expanded` and `aria-controls`.
There is **no application top bar**: the page begins at the top of its column.
### Expanded or collapsed is a preference of this browser
The mode is remembered in this browser's `localStorage` under `ekklesia.dashboard.sidebar` (exactly
`expanded` or `collapsed`; anything else is ignored), so it survives a reload. It is a preference of
the device rather than the person: a narrow laptop and a wide desktop reasonably want different
answers, and DESIGN.md's synced personal preferences belong to the account area, which does not exist
yet. Storage can be missing, blocked, or throw; then the mode is remembered for the page only and
nothing breaks.
The shell reads and writes it through a two-method `SidebarPreference` interface built by the entry
point, so a synced or per-person store replaces the implementation without touching a component. The
entry point likewise builds the browser's media query (`HostViewport`) and history (ADR 0057), and
remains the only file in the package that touches `window`.
### The account area
The signed-in person's control at the bottom of the sidebar opens a product-owned menu following the
WAI-ARIA menu button pattern: `aria-haspopup="menu"` and `aria-expanded` on the trigger; opening moves
focus to the first item (ArrowUp opens on the last); arrow keys, Home, and End move between items;
Escape closes it and returns focus to the trigger; Tab or a press outside closes it and leaves focus
where it went. It contains **only actions that work today** — Sign out. Profile, personal preferences,
appearance, and notifications belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages;
until then there is no dead link to the reserved `/account`.
The shell knows the person only as an opaque user id (`/api/v1/principal`, ADR 0034), which is
never displayed, so the control reads "Your account" with a generic avatar rather than a name or
email. Showing the person's name needs a deliberate principal or profile projection, which this
decision does not add.
### Phones: the same navigation in a drawer
Below the breakpoint the sidebar is not rendered. A single menu button ("Open navigation", with
`aria-expanded` and `aria-controls`) sits above the page — the one control there, not a bar — and opens
a drawer containing the **same** identity slot, navigation, current page, device panel, and account
area, always with labels and with large-control touch targets (48px). The drawer is a real modal:
- `role="dialog"`, `aria-modal="true"`, named "Navigation";
- the page behind it is `inert`, and Tab and Shift+Tab are also kept inside it, for a browser that
does not yet honour `inert`;
- opening moves focus to its close button; Escape, the close button, and the backdrop dismiss it and
return focus to the menu button;
- choosing a destination closes it and moves focus to the new page (ADR 0057), including when the
destination is the page already open;
- Back and Forward close it and still work.
### Changing layout never disturbs the page
The page's `<main>` keeps one position in the tree in both layouts, so rotating a tablet or resizing
a window never remounts the open module. The route comes from the URL and the desktop mode from the
preference, so neither is touched by a layout change. Only an open drawer is dropped, and focus that
was inside navigation that has just gone away lands on the page rather than on the document.
### Shell-local, not yet `@ekklesia/ui`
The tooltip, the account menu, and the drawer live in the shell. ADR 0039 adds an element to
`@ekklesia/ui` once two independent consumers need it, and today the shell is the only one; adding
them to the public package would put unproven API in front of every module. Each is written as a
self-contained, generic piece so it can be promoted unchanged when a module needs one. The shell's
own stylesheet (hover, `:focus-visible`, reduced motion) is a token-only, `ek-shell-`-prefixed string
installed into the shell's document, beside `@ekklesia/ui`'s, and cannot reach a module's isolated
root (ADR 0040).
## Alternatives
- **Another icon library** (Heroicons, Phosphor, Tabler, Material Symbols). Rejected by the
maintainer's decision; Lucide's breadth, consistent stroke style, ISC licence, and per-icon imports
fit a calm interface that many modules will extend.
- **Ekklesia's own SVG set.** Rejected: an icon set is a long-term design and maintenance commitment
that buys nothing a mature open set does not already have.
- **A host-owned map of module ids to icons.** Rejected: the module owns its identity, and a map makes
every new module wait on a shell release. The generic icon is honest until modules declare one.
- **A module-manifest `icon` field now.** Deferred to the manifest-v9 navigation contract, so modules
change manifest version once for icons, sub-pages, and the rest of that contract together.
- **Persisting the mode on the server, per person.** Deferred to the account preferences work; the
interface is shaped so that store can replace this one.
- **CSS-only responsive switching.** Rejected: the drawer must be a modal with focus management, which
needs the layout in script, and a media query adapter keeps it testable.
- **A top bar holding the brand and the menu button on phones.** Rejected: DESIGN.md has no persistent
top bar; one button is all the phone layout needs above the page.
- **Native `<dialog>` for the drawer.** Not chosen for now: its focus return and inertness differ
across the browsers a church's volunteers use, and the shell's own handling is explicit and tested.
## Consequences
- The Dashboard has the shell DESIGN.md describes on desktop, tablet, and phone, proven in unit tests
against in-memory history, viewport, and preference, and in the deploy smoke test in a real browser:
at desktop size (the account menu's Sign out) and at phone size (the drawer's focus, Escape, and
choosing a module), and back again.
- Sign out moved from the header into the account menu.
- The hard-coded product name now lives in one placeholder component, waiting for branding; there is
no other person-facing "Ekklesia" in the authenticated shell.
- `@ekklesia/shell` depends on `lucide-react`. No public package changed.
## Deferred
Church branding in the identity slot (issue #101); module-declared icons and module sub-pages in the
sidebar (manifest v9); group icons and the navigation editor; the account pages and synced personal
preferences, including a per-person sidebar mode; showing the person's name or picture; Settings in the
navigation; promoting the tooltip, menu, or drawer to `@ekklesia/ui`.
@@ -0,0 +1,196 @@
# 0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs
- Status: Accepted
- Date: 2026-10-15
- Extends: [0034](0034-host-owned-browser-authentication.md),
[0039](0039-shared-design-tokens-and-elements.md),
[0040](0040-module-stylesheets-and-artifact-isolation.md),
[0057](0057-dashboard-urls-are-host-owned-routes.md),
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md)
## Context
Every Stage 1 surface still to come — Settings, Displays, module management, Signage as the reference
module — needs the same few interactions DESIGN.md fixes once for the whole product: a product-owned
confirmation for meaningful actions, with typed confirmation for the serious ones (§8); top-center
toasts (§7.4); errors that say what failed and keep technical detail out of the way (§7.3); skeleton
loading that keeps context visible (§7.1); Back plus a page title for nested pages, returning to the
workflow that sent the person there (§2.7, §4.8); and no `window.alert`, `confirm`, or `prompt`
anywhere (§8.1).
`@ekklesia/ui` (ADR 0039) had the presentation of some of these and deliberately not the behaviour.
ADR 0039 deferred "a focus-trap utility"; the package's `Dialog` said a focus trap belonged "in one
place, tested, not sprinkled through call sites"; its `Toast` said there was "deliberately no toast
manager", because a package-level singleton would be shared mutable state across every isolated
artifact on a page (ADR 0040); and ADR 0057 deferred return-to-origin. The only guard against native
dialogs was a regular expression in one shell test.
Each of these is now needed by more than one consumer, which is ADR 0039's bar for adding to the
package.
## Decision
### Placement
The primitives with more than one consumer go in `@ekklesia/ui`; what depends on the Dashboard's own
routes stays in the shell.
| Where | What |
| --- | --- |
| `@ekklesia/ui` | `ConfirmationDialog`; `ToastProvider` and `useToasts`; `ErrorState` (extended); `Skeleton` and `SkeletonGroup`; `PageHeader` (extended with `back`); `Dialog` (now with a focus trap) |
| `@ekklesia/shell` | `parseReturnTarget` and `dashboardBack`: which addresses are safe to go back to |
| Root `biome.json` and `tests/native-dialogs.test.ts` | the native-dialog guard |
The public additions are components and their prop types only. The focus trap, the duration rule,
the phrase matcher, and the detail sanitizer are internal: tested, and used by the components, but
not promised to callers. `@ekklesia/ui` gains no dependency — its few glyphs (`←`, `✕`, the status
symbols) are text, as its status symbols already were, so a module bundle does not inherit an icon
library's licence material through it.
### One focus trap, used by every modal surface
`useFocusTrap` remembers what had focus, moves focus into the surface, wraps Tab and Shift+Tab at its
edges, and returns focus to the remembered control when the surface closes. It reads the focused
element from the surface's **own root node**, so it works inside a module's shadow root, where the
document would only report the shadow host (ADR 0040). `Dialog` uses it, so every dialog — including
the shell's existing device-revocation dialog — now traps and restores focus.
`Dialog` stays rendered in place, never portalled out of an artifact's isolated root. Its scrim
covers the viewport and takes every pointer press, so nothing behind it can be activated, and does
not dismiss the dialog. Escape is now handled on the dialog element and stopped there, rather than on
the whole document, so a drawer or menu the dialog was opened from no longer closes with it. A caller
can make Escape inert while something is in progress (`dismissible={false}`), and give the dialog an
accessible description.
### Confirmation: three severities, chosen by the caller
`ConfirmationDialog` is one element for a simple confirmation, a destructive one, and a destructive
one that also requires a typed phrase. The consuming feature chooses; the element never upgrades or
downgrades severity.
- It is a `Dialog`: named by its title, described by its description, focus trapped and restored.
- **Destructive** is marked by a symbol in the title and on the action, a heavier frame, and the
danger button — never by colour alone — and focus starts on **Cancel**, so a stray Enter is safe.
- **Typed confirmation** is `requiredPhrase`: `DELETE`, or a resource's own name. Confirm is disabled
until the typed value matches exactly and case-sensitively, forgiving only surrounding whitespace and
Unicode composition (NFC), which phone keyboards introduce unasked. The field is labelled with the
phrase, has autocapitalize, autocorrect, and spellcheck off, says politely whether it matches, and
submits on Enter once it does. The typed value is forgotten whenever the dialog closes.
- **Busy** disables both actions, makes Escape inert, and marks the form `aria-busy`, so an action in
flight cannot be abandoned half-done; the caller closes the dialog when it finishes.
### Toasts: a system per React tree, never a package singleton
`ToastProvider` owns one tree's toasts, timers, and top-center region; `useToasts()` returns
`show(options) → id` and `dismiss(id)`. ADR 0040's isolation is why there is no package-level store:
each module artifact and the shell render their own tree, and a store shared through the package
would be mutable state reaching across all of them.
- **Severities:** success, info, warning, and error (`danger`), each with its symbol.
- **Duration:** never under about three seconds (DESIGN.md §7.4), longer for longer text and for
warnings, up to a ceiling. An error stays until dismissed unless its caller gives it a duration — a
routine validation error, say.
- **Calm under load:** at most three routine toasts at once, newest on top; an error is listed first
and never displaced by a routine toast; an identical toast already showing is refreshed rather than
stacked.
- **Accessible:** the region's polite and assertive live regions exist before any toast arrives, so
additions are announced — routine toasts politely, errors assertively — and no toast is a nested
live region of its own. Timers pause while the pointer is over the region or focus is in it (WCAG
2.2 §2.2.1). Every toast has a dismiss control named for it; Escape dismisses the focused toast; when
the last one goes, focus returns to where it was.
- **Clean:** every timer is cleared when its toast goes and when the provider unmounts.
The shell does not mount a provider yet, and nothing is wired to toasts merely to show them. A module
mounts its own provider today. One host-owned region that modules post into would need a module UI
SDK contract, and is deferred.
### Errors: plain message first, technical detail on request
`ErrorState` keeps its existing props and adds `actions` (recovery and remedies — a caller that knows
the viewer can administer the problem passes those remedies too; the element has no notion of roles),
`details` (plain text), `reference` (a diagnostic id), and `announce` (`role="alert"` by default, off
for an error that is part of a page's initial content). Details and the reference sit in a closed
`<details>` disclosure, so they never dominate. `details` is a string, chosen deliberately: never an
exception, so a stack cannot arrive by accident. It is rendered as text, so markup in it is shown and
never interpreted, with control characters removed and its length bounded (2,000 characters).
### Skeletons: shape without pretence
`Skeleton` (text line, block, circle) is decorative and hidden from assistive technology.
`SkeletonGroup` is the region it stands in for: `role="status"`, `aria-busy`, and one label announced
once ("Loading displays") rather than every placeholder. Placeholders pulse gently and stop for a
person whose system asks for reduced motion; there is no percentage, because there is no progress to
report. When the personal reduced-motion preference arrives, it overrides the same single rule.
### Back: a validated origin, or the page's own parent
`PageHeader` takes an optional `back: { label, href?, onBack }` and renders it above the page's one
`h1`: a real link when it has an address (so it can be opened in a new tab), a button otherwise.
Breadcrumbs are not part of it (DESIGN.md §4.8). The element knows nothing about routes; the host
decides where Back goes.
In the shell, `dashboardBack(history, { returnTarget, fallback, label })` decides. A return target is
input from whoever built the link, so it is hostile until proven otherwise, and ADR 0034 requires any
return location to be a validated, same-origin route decision. `parseReturnTarget` accepts only a
string of at most 2 KiB that begins with exactly one `/`, contains no whitespace, control character,
backslash, percent-encoded slash, backslash, or dot, and no dot-segment, query, or fragment, and names
a page the Dashboard actually has. Its answer is a destination, never the raw string, so Back can only
go to the canonical address of a real page. Without a valid target, Back goes to the page's declared
parent. It never calls the browser's `history.back()`: after a bookmark or a link from outside, the
previous entry is not the Dashboard's.
How a link *carries* a return target — a query parameter, or something the module navigation contract
defines — is deliberately not decided here (ADR 0057); whatever carries it hands it to
`parseReturnTarget` first.
### No native dialogs, enforced repository-wide
Biome's scope-aware `suspicious/noAlert` is an **error** in the root `biome.json`, which every package,
surface, and template in this repository lints against. It reports `alert(`, `confirm(`, and
`prompt(`, called bare, through `window` or `globalThis`, or by computed member, and leaves alone a
local function that shares the name, a method such as `dialog.confirm()`, a property such as
`onConfirm`, a string, and a comment. `tests/native-dialogs.test.ts` proves both directions against
fixtures with the repository's own configuration, and scans browser source for the two forms the rule
cannot see — a call through `self`, `top`, `parent`, or `frames`, and borrowing the function without
calling it. The shell's earlier regular-expression test is superseded and removed.
A module repository has its own lint configuration and is outside this repository's reach. It should
enable the same rule; the module development guide says so.
## Alternatives
- **A third-party toast or dialog library.** Rejected: the behaviour is small, it must work inside
isolated roots without portals, and a framework would bring its own styling and a dependency into
every module bundle.
- **A package-level toast singleton (`toast("Saved")` from anywhere).** Rejected for ADR 0040's
reason: shared mutable state across every artifact on the page.
- **Native `<dialog>` with `showModal()`.** Not chosen for now: it promotes the dialog to the top
layer, outside an artifact's isolated root, and its focus return and inertness differ across the
browsers churches use.
- **`history.back()` as Back's fallback.** Rejected: it can leave the Dashboard.
- **Accepting any same-origin path as a return target.** Rejected: an address the Dashboard has no
page for is not a workflow to return to, and a narrow grammar is easier to prove safe.
- **Typed confirmation that ignores case.** Rejected: typing `DELETE` is meant to be deliberate.
- **Enforcing native dialogs at module build time** (`@ekklesia/module-ui-build`). Not chosen: the build
sees a bundle without scope information, so it would either miss shadowed names or report code in
dependencies; a scope-aware lint rule in source is the accurate tool.
- **Exporting the focus trap and helpers.** Deferred: they are tested internals until a caller needs
them outside these components.
## Consequences
- Later Stage 1 pages compose these instead of inventing their own, and modules get the same ones.
- `Dialog`'s Escape is handled on the dialog element rather than on the document. A caller that relied
on Escape reaching the document while focus was elsewhere sees a change; with focus trapped inside
the dialog, that situation no longer arises. The existing test was updated accordingly.
- `@ekklesia/ui` grows by two components, a provider and its hook, and three extended props. It remains
dependency-free beyond `@ekklesia/design-tokens`.
- `pnpm lint` now fails on any native dialog in this repository.
## Deferred
A host-owned toast region that module UI posts into through the module UI SDK; how a link carries a
return target (with the module navigation contract); exporting the focus trap; migrating the shell's
navigation drawer onto the shared focus trap; replacing the device panel's revocation dialog with a
`ConfirmationDialog` (it moves to Settings → Displays); a personal reduced-motion override; and the
module repositories' own lint configuration.
@@ -0,0 +1,189 @@
# 0060: Light, Dark, and System appearance; reduced motion; and one radius scale
- Status: Accepted
- Date: 2026-10-15
- Extends: [0039](0039-shared-design-tokens-and-elements.md),
[0040](0040-module-stylesheets-and-artifact-isolation.md),
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
[0059](0059-shared-interaction-primitives.md)
## Context
DESIGN.md requires every normal first-party interface to support Light, Dark, and System, with System
the default and a person's choice syncing across their devices (§3.2, locked decisions 25–26);
reduced motion following the operating system by default, with a personal override (§3.3, locked
27); a configurable, bounded corner radius (§3.6); shared semantic tokens for every colour (§3.1);
and WCAG AA (§24, locked 92).
ADR 0039 built the semantic token registry and explicitly deferred "a dark theme or any second theme".
The registry had one value per token and was installed as a single `:root` rule. Modules already
consumed it the right way: every module artifact mounts in an isolated shadow root (ADR 0040), and
CSS custom properties inherit through shadow boundaries, so a module reads `var(--ekklesia-…)`
without importing anything at run time.
Measured before this change, the light theme did not meet AA in two places: muted text was 4.10–4.43:1
on the page, canvas, and surface (4.5:1 required), and form controls drew their boundary in
`border.base`, 1.38:1 against a surface (3:1 required for a control's boundary, §1.4.11).
## Decision
### A token may vary by colour scheme, and by motion
`DesignTokenInput` gains two optional values, validated exactly like `value`:
- **`dark`** — the token's value in the dark scheme. Every colour, border colour, and shadow has one;
lengths, weights, and font stacks do not, and keep one value.
- **`reduced`** — the token's value when motion is reduced. Every motion token has one, and it is zero.
A registry built without either emits exactly the single rule it always did. The registry adds
`valueFor(name, scheme)`.
### The stylesheet resolves the mode, in CSS
`installEkklesiaDesignTokens` installs one stylesheet that selects values by CSS alone:
```css
:root { color-scheme: light; /* every default (light) value */ }
@media (prefers-color-scheme: dark) {
:root:not([data-ekklesia-appearance="light"]) { color-scheme: dark; /* dark values */ }
}
:root[data-ekklesia-appearance="dark"] { color-scheme: dark; /* dark values */ }
@media (prefers-reduced-motion: reduce) {
:root:not([data-ekklesia-motion="allow"]) { /* motion durations: 0 */ }
}
:root[data-ekklesia-motion="reduce"] { /* motion durations: 0 */ }
```
(and the same with `:host(…)` for an isolated root that has no inheriting ancestor).
Because the browser resolves it:
- **System is live.** An operating-system change restyles the page immediately — no script, no
listener, no reload.
- **An explicit choice wins.** `light` or `dark` on the theme root overrides the system; `system` (or
no attribute) follows it.
- **Modules follow without a contract change.** Custom properties and `color-scheme` inherit into
every module's shadow root, so the same switch restyles module UI — Signage included — with no SDK
change and no module release. This was verified in a real browser across the shadow boundary.
- **Native UI follows too.** `color-scheme` goes with the tokens, so form controls and scrollbars
match the scheme; nothing critical depends on browser-default styling.
### A small appearance API in `@ekklesia/design-tokens`
Framework-free and dependency-free, like the rest of the package:
- `EkklesiaAppearance` = `system` | `light` | `dark`; `EkklesiaMotionPreference` = `system` | `reduce` |
`allow` — the model DESIGN.md asks for, including a future "allow" override of a reduced-motion
system.
- `parseEkklesiaAppearance` / `parseEkklesiaMotionPreference` read untrusted stored values; anything
unrecognised is `system`.
- `resolveEkklesiaColorScheme` / `resolveEkklesiaReducedMotion` say what is in effect.
- `createEkklesiaThemeController({ target, matchMedia?, appearance?, motion? })` sets the two attributes
**immediately** and answers `colorScheme()`, `reducedMotion()`, `setAppearance`, `setMotion`, and
`subscribe` (which also reports system changes) for code that needs to know.
The host owns the choice. A module reads the theme through tokens and never sets appearance; nothing
in the module UI contract changed.
### The shell applies it before React renders
`surfaces/shell/src/browser/main.tsx` creates the controller on `document.documentElement` before the
first render, with `system` for both. That is the **bootstrap hook** for the account preferences
work: it will read a remembered choice synchronously and pass it here, so an explicit Light or Dark is
in place before the first frame. Nothing is persisted in this decision, and there is no temporary
user-facing switch to remove later.
The Dashboard page declares `<meta name="color-scheme" content="light dark">` and takes its body
background and text colour from tokens, so the browser's own canvas is already in the system's scheme
before any script runs: a dark system never flashes white while the shell loads. (A future explicit
choice opposite to the system can still show the system's empty canvas for the moment before the
module script runs; that is the remaining cost of a page with no inline script.)
The **display host stays Light**, whatever its operating system prefers. A screen in a lobby is
configured by whoever set it up, not by a person's appearance preference, and Stage 0 displays must
look exactly as they did; motion there still follows the system.
### The dark theme is designed, not inverted
Teal-tinted charcoal backgrounds in an elevation ladder (page `#0e1615` → canvas → surface → raised),
never pure black; primary text `#e4edeb`, never pure white; the same teal accent, slightly lifted, with
dark text on it rather than a glowing control; status colours desaturated to sit on dark tints; deeper
shadows; and a darker overlay. It is the same product in both schemes.
### Contrast is tested, and two light pairings were fixed
`packages/design-tokens/tests/contrast.test.ts` computes WCAG contrast for 41 pairings the elements
actually use — text on every background tier, quiet and selected navigation, primary and danger
buttons, field hints and errors, status notices and badges, control boundaries, and focus rings — in
both schemes: 4.5:1 for text, 3:1 for control boundaries and focus indication. A real-browser check of
rendered elements in both schemes agrees.
Two intentional, visible light-theme changes make it pass:
- `text.muted` darkens from `#6c7b77` to `#5d6d69` (5.44:1 on a surface).
- A new **`border.control`** token (`#7a8d89`, 3.5:1) draws form controls' boundaries instead of
`border.base`, which stays the quieter decorative border of cards and panels. `border.strong` is
redefined to be stronger than `border.control` in both schemes, since it is the hovered control's
border.
`background.placeholder` is new: the skeleton's colour, visibly distinct from the page and a surface
in both schemes (it had used `background.inset`, which in a dark scheme is correctly darker than the
page and made skeletons vanish).
### Motion and radius are tokens
A new **`motion`** category: `motion.duration-short` (160ms; transitions such as the sidebar and a
toast arriving) and `motion.duration-ambient` (1.6s; a loading placeholder's pulse, and the spinner
at 0.625 of it). Both are zero when motion is reduced, so an element that animates for a motion-token
duration needs no reduced-motion rule of its own; the per-element rules in `@ekklesia/ui` and the
shell are removed, which is also what lets an explicit `allow` work.
**`radius.scale`** (default `1`) multiplies `radius.sm`, `radius.md`, and `radius.lg`, which become
`calc(<today's value> * var(--ekklesia-radius-scale))`; `radius.none` and `radius.pill` do not scale.
At the default nothing looks different. A later appearance or branding control changes one value to
make every corner squarer or rounder; it should offer a bounded range (DESIGN.md §3.6).
### Every Dashboard colour and corner is a token, and that is checked
The sign-in, verification, and recovery card (`AuthCard`) and the module loading and error text
(`ModuleUiFrame`) carried literal colours; they now use tokens, and the sign-in action is the
platform's primary button. `tests/theme-guards.test.ts` scans the shell's Dashboard source and
`@ekklesia/ui` for raw colours and literal corner radii, and `@ekklesia/ui`'s own tests require its
stylesheet's colours, radii, and durations to be tokens. The display host is the named exception,
pinned light, until displays move to Settings → Displays.
## Alternatives
- **Toggle a class from JavaScript on OS changes.** Rejected: CSS media queries already do it, live,
in every isolated root, with nothing to wire up or forget to unsubscribe.
- **A second token registry or stylesheet per theme.** Rejected: two registries drift; one token with
two values cannot lose its dark half without a test failing.
- **Automatic dark values (inverting, or adjusting lightness).** Rejected: an inverted theme is exactly
the naive dark mode DESIGN.md warns against, and generated colours would not be reviewable.
- **Inline bootstrap script to apply a stored choice before paint.** Rejected: the Dashboard has no
inline script so a strict `script-src` needs no exception; the meta element covers the system case.
- **Per-element `prefers-reduced-motion` rules.** Replaced: they cannot honour an explicit `allow`, and
every new animation would have to remember one.
- **Many radius tokens per component.** Rejected: one multiplier over a three-step scale is what a
bounded "roundness" control needs.
- **Letting the display host follow the system.** Rejected for now: it would change Stage 0 screens
overnight when a kiosk's OS setting changes.
## Consequences
- The Dashboard and every module UI are Light or Dark by the operating system today; an explicit choice
is one call once the account preferences exist.
- `@ekklesia/design-tokens` adds the `motion` category, `dark` and `reduced` values, `valueFor`, three
tokens (`border.control`, `background.placeholder`, `radius.scale`) plus two motion tokens, and the
appearance API. `@ekklesia/ui` uses them. Both changes are additive; code that switches exhaustively
over `DesignTokenCategory` sees one more case.
- Visible changes in light: slightly darker muted text and form-control borders, and the sign-in card
in the platform's colours.
## Deferred
The Account → Appearance page and synced preference storage; a church theme editor and per-installation
overrides of dark values; branding; the display host's own appearance and its pairing and offline
screens' colours; the bounded radius range a future control offers; and exposing the resolved scheme
to module code (modules read it through tokens today).
@@ -0,0 +1,177 @@
# 0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`
- Status: Accepted
- Date: 2026-10-15
- Extends: [0027](0027-dashboard-navigation-and-flat-modules.md),
[0028](0028-module-operational-lifecycle.md),
[0029](0029-module-configuration-and-secrets.md),
[0057](0057-dashboard-urls-are-host-owned-routes.md),
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
[0059](0059-shared-interaction-primitives.md)
## Context
DESIGN.md makes Settings the one home for shared administration (§9): church-wide configuration in
Settings, personal configuration under the account area (§9.2), every module's settings reachable
through Settings rather than an independent system inside the module (§9.3), and people without
management permission not shown administration they cannot carry out (§9.2). ROADMAP §5.3 asks that
the navigation and ownership model exist before every category is functional.
ADR 0057 reserved `/settings` and served the Dashboard page throughout it, but defined no page there.
Three things had to be decided before any section could be built: the address of each page, how the
Settings hierarchy sits in the shell's one sidebar (ADR 0058), and how the browser learns which
Settings destinations a person may see — it knew only that they were signed in, plus each module's
own permission hints (ADR 0026).
## Decision
### The URL model
```text
/settings the Settings landing page
/settings/general General: how this installation works for everyone
/settings/branding Branding: the church's name, logo, and colours
/settings/displays Displays: paired screens
/settings/discover enters Discover at Browse (respelled in place, below)
/settings/discover/browse Browse: find and install modules
/settings/discover/installed Installed: see and manage installed modules
/settings/discover/updates Updates: module and platform updates
/settings/system System: version, health, and diagnostics
/settings/<module-id> that module's settings
/settings/<module-id>/… reserved for a module's deeper settings pages
```
The distinction is the decision:
- **`/settings/discover/…` is module lifecycle** — finding, installing, turning on and off, inspecting,
updating, and eventually removing modules.
- **`/settings/<module-id>` is configuring that module.** It is never nested under Discover or under a
`modules` segment: a module's settings are a first-class Settings destination.
**"Modules" is the product's word**, in the interface as in the architecture — never "features",
"apps", "extensions", or "add-ons". "Discover" is the name of a navigation destination, not a
replacement for it: inside Discover the pages say "Discover modules", "Installed modules", and "Module
updates". Implementation terms — Core, a platform module — never appear.
**Entering Discover opens Browse, at one address.** `/settings/discover` reads as Browse, whose
canonical spelling is `/settings/discover/browse`; the shell respells it in place (`replace`, no new
history entry), exactly as it corrects a trailing slash (ADR 0057). There is one meaningful Browse
address rather than two, and Discover's own item in the navigation links to it.
**`/settings` is a real landing page**, never a redirect to the first section: the two groups the
person may open, each item named and described in a line. It is an index, not a dashboard.
Anything deeper than these addresses is `not-found` in the shell until its page exists. The API already
serves the page throughout `/settings/*`, and the proxies already forward it (ADR 0057), so deeper pages
need no server or deployment change.
### Module ids cannot collide with a Settings section
A module id is a **stable URL identifier**: its page is `/<module-id>` and its settings
`/settings/<module-id>`. So the platform's own Settings section names join the top-level names in Core's
one list, `RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`), now in two documented groups:
| Group | Names |
| --- | --- |
| Top-level namespaces (`/<name>`) | `account`, `api`, `assets`, `display`, `settings` |
| Settings sections (`/settings/<name>`) | `branding`, `discover`, `displays`, `general`, `system` |
Discovery refuses a module that declares any of them (`MODULE_ID_RESERVED`), at startup, in
`pnpm modules:validate`, and in package verification. Look-alikes stay valid — `general-info`,
`discovery`, `display-board`, `systems` — and so does `modules`, since module management is Discover.
Discover's views live beneath `/settings/discover/`, so they cannot collide with a module and are not
reserved. `apps/api` tests hold Core's Settings group equal to the shell's `SETTINGS_SECTIONS`, so a
section cannot be added to one without the other.
### Settings sits in the same sidebar
Settings is one item in the shell's one navigation (ADR 0058), after the modules and above the account
area. While a Settings page is open it expands in place into two labelled groups — **Platform**
(General, Branding, Displays, Discover, System) and **Modules** (each module's settings) — and inside
Discover, Browse, Installed, and Updates beneath it. The rest of the navigation stays where it is, so
leaving Settings is one click on any other item; it is not a second application with its own shell.
The phone drawer shows the identical hierarchy. Exactly one item carries `aria-current="page"`.
### Which destinations a person sees is evaluated by the server
`GET /api/v1/dashboard/settings` (`apps/api/src/dashboard-settings-routes.ts`), readable by any signed-in
person (`core.modules.ui.read`, like the navigation beside it), answers which sections and which modules'
settings this person may see, through Core's one authorization evaluator:
| Destination | Shown to |
| --- | --- |
| Displays | `core.devices.manage` |
| Discover | `core.modules.lifecycle.manage` — deciding which modules run |
| a module's settings | `core.modules.configuration.manage` (ADR 0029), for every module that declares configuration |
| General, Branding, System | the **platform owner** only, until each defines its own permission |
General, Branding, and System have no permission yet because nothing in them exists to authorize.
Rather than invent three speculative permissions, they are shown only to the platform owner — the one
principal who already holds every permission — and each gains a delegable permission in the work that
builds it. Platform updates in Discover → Updates will likewise get their own permission then.
A module **has settings** when its manifest declares configuration (ADR 0029). That is the existing,
authoritative signal; no manifest change is needed. A disabled or blocked module with settings is listed
with `available: false`.
These are **presentation hints, not authority**, as ADR 0026's are. Every route behind a section keeps
its own enforcement, unchanged, and a test proves the data behind each is still refused without its
permission. An evaluation failure is a `503`, never an empty answer that would look like a permissions
problem. The browser decides nothing about permissions itself.
### What each Settings address shows
- A section or module the person may see shows its page.
- A section the person may not see, or a module they know of but cannot configure (or that has no
settings), shows *These settings aren't available* — without saying which.
- A disabled module's settings show *This module is turned off*. Never a silent redirect.
- A name that is no module the person knows of is *Page not found*.
- When the Settings view cannot be loaded, the Settings link is withheld and its pages say so, with
Retry; the rest of the Dashboard keeps working.
- When a person can see nothing in Settings, the Settings link is not shown at all.
### Pages are honest placeholders
Every section, Discover view, and module's settings page in this decision is a placeholder: Back to
Settings (`PageHeader`, ADR 0059), a title and one-line description, and *Not available yet* with what
will happen there. No fake controls, no dead fields. The work that builds each replaces its placeholder.
Back goes to the page's parent, Settings. A validated return target (ADR 0059) takes precedence once
something carries one, so `/signage` → `/settings/signage` can return to `/signage`; how a module links
into its settings with a return target is the module navigation contract's to define.
## Alternatives
- **`/settings/modules` for module management, and `/settings/modules/<module-id>` for a module's
settings.** Rejected: it nests a module's configuration under its lifecycle and makes "configure
Signage" two levels deep. Discover is lifecycle; `/settings/<module-id>` is configuration.
- **`/settings/discover` as a page of its own beside `/settings/discover/browse`.** Rejected: two
meaningful addresses for one view.
- **Redirecting `/settings` to the first section.** Rejected: a landing page says what Settings holds,
and the first section differs per person.
- **Replacing the module navigation with the Settings hierarchy while in Settings.** Rejected: DESIGN.md
keeps one sidebar with Settings in it (§4.2), and leaving Settings should not require a second
navigation model.
- **Probing each section's API for a `403` from the browser.** Rejected: several requests per page view,
and it confuses "may not" with "failed".
- **Defining General, Branding, and System permissions now.** Rejected as speculative; the owner-only
interim rule is deny-by-default with the owner's existing bypass.
- **A manifest field declaring "has settings".** Unnecessary: declared configuration already says so;
module-owned settings pages arrive with the module navigation contract.
- **Reserving Discover's view names.** Unnecessary: they live under `/settings/discover/`.
## Consequences
- Every later Settings section has an address, a navigation entry, a permission rule, and a placeholder
to replace.
- A module may not be called `branding`, `discover`, `displays`, `general`, or `system`. No known module
is; one that was would fail discovery and must be renamed.
- The shell asks one more endpoint at bootstrap, alongside the navigation.
- A person without any administrative permission sees no Settings link.
## Deferred
The real General, Branding, Displays, Discover (catalog, installation, lifecycle, updates), and System
pages, and each one's permission; module settings screens and their deeper pages; how a module links to
its settings with a validated return target (the module navigation contract, manifest v9); moving the
transitional device panel into Settings → Displays; and the account area.
@@ -0,0 +1,199 @@
# 0062: The account area, and appearance preferences that belong to the account
- Status: Accepted
- Date: 2026-10-14
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
[0044](0044-self-hosted-reference-deployment.md),
[0045](0045-split-dashboard-and-api-origins.md),
[0057](0057-dashboard-urls-are-host-owned-routes.md),
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
[0060](0060-light-dark-system-theme-and-motion.md),
[0061](0061-settings-hierarchy-and-module-settings-routes.md)
## Context
DESIGN.md puts the signed-in person at the bottom of the sidebar (§4.5), keeps personal configuration
under that account area rather than in church-wide Settings (§9.2), and requires Light, Dark, and
System appearance with the person's choice **syncing across their devices** (§3.2, locked decision
25), System being the default (locked decision 26).
ADR 0060 built the theme: the token stylesheet resolves appearance and motion in CSS, a controller sets
two attributes on the theme root, and `main.tsx` is the bootstrap hook where a remembered choice would
be supplied. It deferred the Account → Appearance page and synced storage, and it noted the remaining
cost of a page with no inline script: an explicit choice opposite to the system can show the system's
empty canvas until the module script runs.
Three things were missing. The shell knew the person only as an opaque user id
(`/api/v1/principal`), so the account control could not say who was signed in. There was nowhere to
keep a personal preference. And the Dashboard page is one frozen snapshot with one `ETag`, served in
the split topology from a hostname that never receives the API's session cookie (ADR 0045), so the
server cannot write a person's choice into the HTML.
## Decision
### The account area
```text
/account Account: who you are signed in as, and your own settings
/account/appearance Appearance: Light, Dark, or System; and reduced motion
```
Personal, never church administration. It needs no management permission, it sits outside the Settings
hierarchy (ADR 0061), and the sidebar's Settings group does not expand on it. `account` was already a
reserved top-level name (ADR 0057), so the API already served the page here and no server or proxy
change was needed for the addresses. Account sections live beneath `/account/`, so they cannot collide
with a module id and are not reserved. Anything else under `/account/` is *Page not found* until it
exists.
**The account control names the person**: their initials, their name, and their email, at the bottom
of the sidebar and in the phone drawer. Its menu offers exactly the destinations that work: Account,
Appearance, and Sign out. Account and Appearance are real links. When the account cannot be loaded, the
control falls back to "Your account" and keeps every destination.
**No avatar image.** The account's `image` is an arbitrary URL the platform has never vetted.
Rendering it would make every Dashboard visit a request to whatever host it names, and it has no CSP
story. Initials are drawn from the name instead, or from the email when the name is blank. Showing a
person's picture needs an upload and serving path the platform owns, which is later work.
There is **no profile editing**. Name and email are shown as Better Auth stores them, and nothing on
the page pretends to be editable.
### Preferences belong to the account
One Core table, one row per person, **one typed column per preference**:
```text
ekklesia_core_user_preference
user_id text primary key, references ekklesia_core_user(id) on delete cascade
appearance text null, or one of 'system' | 'light' | 'dark' (CHECK)
motion text null, or one of 'system' | 'reduce' | 'allow' (CHECK)
created_at, updated_at
```
- **`null` means never chosen** and resolves to the default (System, System) when read. A column
default would make "chose System" indistinguishable from "never chose". It would also force a value
nobody chose onto every existing row when a preference is added. A person with no row has chosen
nothing. Reading never creates a row.
- **A new preference is a new nullable column** in a new migration, beside these. That is not one
table per preference, and it is not a key/value or JSON document a client could fill with anything.
Values are checked twice: by Core's strict parser (`parseAccountPreferencesUpdate`) and by the
database's `CHECK` constraints.
- Core declares the value sets itself (`ACCOUNT_APPEARANCES`, `ACCOUNT_MOTION_PREFERENCES`), because
Core cannot import the design tokens. An `apps/api` test holds them equal to `EKKLESIA_APPEARANCES`
and `EKKLESIA_MOTION_PREFERENCES`.
- Migration `core:0008_user_preferences` creates the table and destroys nothing. Like every migration,
it is applied only by an operator (`pnpm migrations:apply`).
The sidebar's expanded or collapsed mode is **not** moved here. It stays a preference of this browser
on this device (ADR 0058).
### The API is self-only by construction
| Route | Body | Answer |
| --- | --- | --- |
| `GET /api/v1/account` | none | `{ name, email, preferences: { appearance, motion } }` |
| `PATCH /api/v1/account/preferences` | `{ appearance?, motion? }`, at least one | `{ appearance, motion }`, everything now in effect |
- **Neither route accepts a user id**, not in the path, the query, or the body. The account is always
the one the session names, so no request can read or change anyone else's, whatever the caller holds.
A body naming any key besides a known preference, or any value outside its set, is a `400` with
nothing changed.
- **`PATCH` changes only what it names.** Changing motion on a phone never overwrites an appearance just
chosen on a laptop.
- **Both require `core.account.self.manage`**, a new Core permission with an `authenticated` baseline.
Every signed-in person holds it, and choosing dark mode needs no grant. It is a declared permission
rather than a hand-written "is signed in" check, following `core.modules.ui.read` (ADR 0026). A paired
device never satisfies `authenticated`, and an anonymous caller is refused; both get `403`. The route
also checks that the principal is a person before reading.
- The mutation takes an exact trusted `Origin` and a bounded body before authorization, like every Core
mutation. Responses are `no-store` and vary by `Origin, Cookie`.
- **The browser learns name, email, and preferences, and nothing else**: not the image URL,
verification state, timestamps, or user id.
### First paint: a classic pre-paint script, never inline
The server cannot render the choice into the page. The page is one snapshot with one validator for
everyone, and in the split topology its hostname never sees the session cookie. So:
1. The page loads `/assets/ekklesia-dashboard/appearance.js` in `<head>`, after
`<meta name="color-scheme">`. It is a classic, same-origin, render-blocking script of about a
kilobyte, and **never inline**, so a strict `script-src` still needs no exception. ADR 0060 rejected
an inline script for exactly that reason. It is a fixed asset route beside `app.js` with its own
strong `ETag`. The proxies already forward `/assets/ekklesia-dashboard/*`.
2. It reads this browser's **cached copy** of the choice: one key, `ekklesia.dashboard.appearance`,
holding two words (for example `dark reduce`). It sets the two theme attributes on the root. For an
explicit Light or Dark it also narrows the colour-scheme element, so the browser's canvas is painted
in that scheme before the token stylesheet arrives. An invalid, missing, or unreadable cache does
nothing, and the page follows the system.
3. `main.tsx` starts the ADR 0060 controller from the same cache, so the two agree.
**The cache is never the source of truth.** Every Dashboard load replaces it with what the account
holds, so a choice made on another device takes effect here from the next load on. A choice is saved to
the account first-class: it applies at once, the account's answer is what stays applied, and a failed
save puts the account's value back and says so. The cache holds nothing about the account. It is
cleared on sign-out and whenever the page resolves to signed-out, so the next person to use the browser
starts from the system's setting. An account that cannot be loaded leaves the cache alone rather than
guessing.
This removes the cost ADR 0060 noted. An explicit choice is in place from the first frame on any browser
that has loaded the Dashboard before. A first visit, a new device, or a blocked storage follows the
system until the account answers, moments later.
### Changes apply live, and other tabs follow
A choice takes effect through `setAppearance` and `setMotion` on the one controller, with no reload and
no Save button. Isolated module roots follow because the attributes and custom properties inherit, as in
ADR 0060.
**Other tabs** of the Dashboard in the same browser follow through the browser's `storage` event on the
cache key, with no request. Sign-out in one tab returns the others to the system as well.
**Cross-device realtime is not provided**: another device picks up a change on its next load. That meets
DESIGN.md's "syncs across devices" without a push channel, which would cost a persistent connection per
open Dashboard for a setting people change rarely.
### Unchanged
- **The display host stays Light** (ADR 0060). It loads neither the pre-paint script nor the account.
A lobby screen is not a person.
- Module UIs read the theme through tokens and never set it, and the module UI contract is unchanged.
- No church theme, brand colours, radius control, administrator-enforced mode, or Signage appearance.
## Alternatives
- **Render the choice into the HTML on the server.** Rejected: one frozen snapshot and `ETag` for
everyone (ADR 0044), and in the split topology the Dashboard hostname never receives the session
cookie (ADR 0045). Per-person HTML would also put account data into a cacheable page.
- **An inline bootstrap script.** Rejected, as in ADR 0060: it costs a strict `script-src` an exception.
A same-origin external script does the same job.
- **Treat `localStorage` as the preference store.** Rejected: it does not follow the person to another
device, and it would survive into the next person's session on a shared computer.
- **A cookie carrying the choice.** Rejected: in the split topology a cookie the Dashboard origin could
read would be a second, cross-origin cookie policy for one preference. It would also travel on every
request.
- **A key/value or JSON preference table.** Rejected: values the server cannot type-check, and a column
any client could fill. One typed, constrained column per preference costs one migration per
preference, which is the point.
- **One table per preference.** Rejected: many tables for one row per person.
- **Fold the account into `/api/v1/principal`.** Rejected: the principal is the platform's identity
primitive, also read by the display host and the module UI host. Name and email are the account area's
concern, behind their own permission.
- **Load the account's `image`.** Rejected, as above.
- **Realtime cross-device sync.** Deferred: next-load reconciliation meets the requirement.
- **Move the sidebar mode to the account.** Not done: it is a per-device preference (ADR 0058).
## Consequences
- Core gains `@ekklesia/core/account` (schema, store, value sets), migration `0008_user_preferences`,
and a seventh Core permission, `core.account.self.manage`. Every composed registry and the Doctor's
count grow by one.
- The Dashboard serves three host-owned files instead of two, and bootstrap makes one more request,
alongside the navigation and Settings. An account that fails to load never stops the Dashboard.
- Browser storage now holds two things: the sidebar mode and the appearance cache. A boundary test pins
each read and write.
- No public `@ekklesia/*` package changes.
## Deferred
Profile editing, an uploaded avatar the platform serves itself, notification and other personal
preferences, which arrive as further columns. Also deferred: realtime cross-device sync, a church theme
or administrator-enforced appearance, and the display host's own appearance.
+10 -2
View File
@@ -44,7 +44,7 @@ ADRs capture durable choices that affect more than one package or phase.
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted - [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted - [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted - [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted - [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted (session lifetime superseded by 0056)
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted - [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted - [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted - [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
@@ -60,6 +60,14 @@ ADRs capture durable choices that affect more than one package or phase.
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted - [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
- [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted - [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted
- [0051: Module packages declare their own license and carry its text](0051-module-package-license-material.md) — Accepted - [0051: Module packages declare their own license and carry its text](0051-module-package-license-material.md) — Accepted
- [0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material](0052-reviewed-license-supplements.md) — Proposed - [0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material](0052-reviewed-license-supplements.md) — Accepted
- [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted - [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted
- [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted - [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
- [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted
- [0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary](0058-dashboard-shell-navigation-and-lucide-icons.md) — Accepted
- [0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs](0059-shared-interaction-primitives.md) — Accepted
- [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted
- [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted
- [0062: The account area, and appearance preferences that belong to the account](0062-account-area-and-synced-appearance-preferences.md) — Accepted
+38 -3
View File
@@ -343,7 +343,11 @@ permission the inventory does; organizing requires the new platform permission
shell renders that arrangement and computes none of it. shell renders that arrangement and computes none of it.
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no **Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment Dashboard Home or widgets, no design system, and no client routing (client routing and an Overview
landing page have since landed, see
[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md), and the shell itself is now the
collapsible sidebar, account menu, and phone drawer of
[ADR 0058](decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). A production CSP and deployment
for the built shell, general asset publication, and the standalone host remain future work. Phase 7B for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
pass 1 later supplies the host-owned sign-in flow described below. pass 1 later supplies the host-owned sign-in flow described below.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
@@ -588,7 +592,29 @@ registry that constructs is one that can be emitted as CSS without escaping. Tha
exactly what makes a future theme editor implementable rather than aspirational: the values it would exactly what makes a future theme editor implementable rather than aspirational: the values it would
edit are already labelled, described, categorised, and enumerable. `@ekklesia/ui` is the small set of edit are already labelled, described, categorised, and enumerable. `@ekklesia/ui` is the small set of
elements a real module dashboard cannot be built without, every value a token reference, with real elements a real module dashboard cannot be built without, every value a token reference, with real
`:focus-visible` treatment and status that never depends on colour alone. `pnpm boundaries` opens `:focus-visible` treatment and status that never depends on colour alone. It now also carries the
shared interactions — a focus-trapping `Dialog` and `ConfirmationDialog`, a per-tree toast system,
`ErrorState` with collapsed technical detail, skeletons, and `PageHeader`'s Back — and the lint
refuses browser-native dialogs repository-wide
([ADR 0059](decisions/0059-shared-interaction-primitives.md)). Every colour token has a designed dark
value, and the token stylesheet selects Light, Dark, or the system's scheme — and zeroes motion
durations when motion is reduced — in CSS alone, so the Dashboard and every module root follow the
operating system live; the host chooses an explicit appearance through a small controller, applied
before React renders. Contrast is tested for every pairing the elements use, in both schemes
([ADR 0060](decisions/0060-light-dark-system-theme-and-motion.md)). Settings is one host-owned
hierarchy in the same sidebar: a landing page, the platform's General, Branding, Displays, Discover
(module lifecycle: Browse, Installed, Updates), and System, and each module's settings at
`/settings/<module-id>`. Which of them a person sees is evaluated by the server
(`GET /api/v1/dashboard/settings`) as presentation hints, while every route behind them keeps its own
enforcement; the Settings section names are reserved module ids
([ADR 0061](decisions/0061-settings-hierarchy-and-module-settings-routes.md)). Personal configuration
lives apart from it, in the account area: `/account` shows who is signed in, and `/account/appearance`
holds Light, Dark, or System and reduced motion. These preferences belong to the account, stored by
Core one typed column per preference and served only to their owner (`GET /api/v1/account`,
`PATCH /api/v1/account/preferences`, `core.account.self.manage`), so they follow the person to every
device. A small same-origin script in the page's `<head>` applies this browser's cached copy before
first paint, and the account's answer replaces it on every load
([ADR 0062](decisions/0062-account-area-and-synced-appearance-preferences.md)). `pnpm boundaries` opens
exactly two shared roles to modules **by name**, and pins both design packages beneath everything exactly two shared roles to modules **by name**, and pins both design packages beneath everything
else. The Dashboard and display install the defaults at their document root; custom properties else. The Dashboard and display install the defaults at their document root; custom properties
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
@@ -692,10 +718,19 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
reviewed supplements checked in with the packager add it reviewed supplements checked in with the packager add it
([ADR 0052](decisions/0052-reviewed-license-supplements.md)). Each supplement matches one exact ([ADR 0052](decisions/0052-reviewed-license-supplements.md)). Each supplement matches one exact
package name, version, and content digest; anything else fails closed. package name, version, and content digest; anything else fails closed.
- **Corresponding source.** A native package that redistributes code under the LGPL, the MPL, or
a similar licence is approved for public distribution only together with a sibling
corresponding-source artifact. The artifact is built offline from a reviewed source recipe and a
verified source cache, and linked to the package by digest
([ADR 0055](decisions/0055-corresponding-source-artifacts.md)). Building and testing packages is
unaffected.
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links, - **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
special files, and escaping paths. It checks every digest and identity, the target and runtime, special files, and escaping paths. It checks every digest and identity, the target and runtime,
runs the platform's own module, schema, and migration validation, and loads the backend once in a runs the platform's own module, schema, and migration validation, and loads the backend once in a
short-lived process with an empty environment. Only then does it extract anything. short-lived process with an empty environment. Only then does it copy the package to a requested
output directory. In the hardened container invocation, `/tmp` remains `noexec`; the temporary
candidate lives on a dedicated, bounded executable tmpfs because a native addon's code must be
mapped executable to load.
- **A native package's host checks.** It is accepted only on a host whose operating system, CPU, - **A native package's host checks.** It is accepted only on a host whose operating system, CPU,
C library family and version, and Node-API version Node.js itself reports as compatible. C library family and version, and Node-API version Node.js itself reports as compatible.
- **ELF files.** Every ELF file in it must be built for the target's machine. - **ELF files.** Every ELF file in it must be built for the target's machine.
+74 -14
View File
@@ -79,7 +79,8 @@ No real values are shown here — configure per environment and never commit the
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. | | `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. | | `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), with the API's origin written into it. Served from the API's origin, or from the Dashboard's own hostname through the proxy in the split topology. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). | | `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), with the API's origin written into it. Served from the API's origin, or from the Dashboard's own hostname through the proxy in the split topology. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). | | `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's application script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
| `GET /assets/ekklesia-dashboard/appearance.js` | Ekklesia | The Dashboard's small pre-paint appearance script, loaded in `<head>`. `no-cache` with its own strong `ETag`. See [Dashboard host](#dashboard-host) and [Account](#account). |
| `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). | | `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). |
| `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). | | `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). | | `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
@@ -87,6 +88,8 @@ No real values are shown here — configure per environment and never commit the
| `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). | | `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
| `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's HTTP and realtime roots live, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). | | `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's HTTP and realtime roots live, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). |
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). | | `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
| `GET /api/v1/account` | Ekklesia | The signed-in person's own name, email, and preferences. Requires `core.account.self.manage` (baseline `authenticated`). Takes no user id. See [Account](#account). |
| `PATCH /api/v1/account/preferences` | Ekklesia | Change some of the signed-in person's own preferences. Requires `core.account.self.manage` and a trusted `Origin`. See [Account](#account). |
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). | | `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). | | `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
| `GET /api/v1/module-ui/<module-id>/<version>/display` | Ekklesia | Publishes the exact validated display artifact for that eligible/running module. Artifact bytes are anonymous trusted code; this route is not the authorization boundary. See [Module display host](#module-display-host). | | `GET /api/v1/module-ui/<module-id>/<version>/display` | Ekklesia | Publishes the exact validated display artifact for that eligible/running module. Artifact bytes are anonymous trusted code; this route is not the authorization boundary. See [Module display host](#module-display-host). |
@@ -147,14 +150,16 @@ same-origin topology it does so from the same origin as `/api`: `EKKLESIA_DASHBO
unset, the session cookie stays host-only, and no second static server or proxy file root is unset, the session cookie stays host-only, and no second static server or proxy file root is
needed. In the split topology needed. In the split topology
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the proxy forwards ([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the proxy forwards
the Dashboard's own hostname to the same two routes; the API does not inspect `Host`. It works the Dashboard's own hostname to the same routes; the API does not inspect `Host`. It works
exactly like the display host: exactly like the display host:
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the - **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the
exact URLs of two built files, `dist/browser/index.html` and exact URLs of three built files: `dist/browser/index.html`,
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any `dist/browser/assets/ekklesia-dashboard/app.js`, and the pre-paint
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256 `dist/browser/assets/ekklesia-dashboard/appearance.js`
strong `ETag` for each. ([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
`startApi()` reads all three before importing any module code or binding the listener, and keeps
one frozen snapshot of their text with a SHA-256 strong `ETag` for each.
- **The API's origin is written in at startup.** The built page carries one empty - **The API's origin is written in at startup.** The built page carries one empty
`<meta content="" name="ekklesia-api-origin" />`. Before computing the page's `ETag`, `startApi()` `<meta content="" name="ekklesia-api-origin" />`. Before computing the page's `ETag`, `startApi()`
fills it with the identity base URL's origin, which is where `/api` lives in either topology. The fills it with the identity base URL's origin, which is where `/api` lives in either topology. The
@@ -166,33 +171,35 @@ exactly like the display host:
`DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources `DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
already opened. It never carries a path or the filesystem error. Build the shell first; already opened. It never carries a path or the filesystem error. Build the shell first;
`pnpm api:start` already runs the full build. `pnpm api:start` already runs the full build.
- **Two exact routes, never a directory.** `GET /` answers the page and - **Exact routes, never a directory.** `GET /` (and the Dashboard's own pages, ADR 0057) answers
`GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and the page, `GET /assets/ekklesia-dashboard/app.js` the application script, and
`GET /assets/ekklesia-dashboard/appearance.js` the pre-paint script. No request value selects a
file, and
nothing touches the filesystem while answering. Every other path, including `/index.html`, nothing touches the filesystem while answering. Every other path, including `/index.html`,
`/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary `/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary
`404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client `404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client
router, and emailed links land on `/` with a query (see router, and emailed links land on `/` with a query (see
[The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's [The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's
implicit `HEAD`) are also the ordinary `404`. implicit `HEAD`) are also the ordinary `404`.
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two - **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the
files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive served files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
a `404` for it. a `404` for it.
Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's
CORS policy does not apply. The bytes grant no authority: the page renders sign-in until CORS policy does not apply. The bytes grant no authority: the page renders sign-in until
`/api/v1/principal` says otherwise. `/api/v1/principal` says otherwise.
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` | | Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` and `appearance.js` |
| --- | --- | --- | | --- | --- | --- |
| `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` | | `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` |
| `Cache-Control` | `no-cache` | `no-cache` | | `Cache-Control` | `no-cache` | `no-cache` |
| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script | | `ETag` | strong SHA-256 of the page | strong SHA-256 of each script |
| `X-Content-Type-Options` | `nosniff` | `nosniff` | | `X-Content-Type-Options` | `nosniff` | `nosniff` |
| `X-Frame-Options` | `DENY` | — | | `X-Frame-Options` | `DENY` | — |
| `Content-Security-Policy` | `frame-ancestors 'none'` | — | | `Content-Security-Policy` | `frame-ancestors 'none'` | — |
| `Referrer-Policy` | `no-referrer` | — | | `Referrer-Policy` | `no-referrer` | — |
**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every **Caching.** The shell emits fixed, unhashed file names, so every file is revalidated on every
load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match` load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match`
(strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and (strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and
`Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry `Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry
@@ -233,7 +240,9 @@ password, URL, or stack reaches browser output or logging. The form offers **no
because public registration is not a supported production flow. It does offer **Forgot password?**, because public registration is not a supported production flow. It does offer **Forgot password?**,
which is now a real backend flow (ADR 0038). which is now a real backend flow (ADR 0038).
Sign-out posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds, Sign-out is the account menu's one item, at the bottom of the sidebar or the phone drawer
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). It
posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
resolves the principal again. The host does not equate hiding the shell with invalidating a session. resolves the principal again. The host does not equate hiding the shell with invalidating a session.
During that transition the Dashboard subtree is unmounted, which invokes the generic module During that transition the Dashboard subtree is unmounted, which invokes the generic module
loader's ordinary cleanup and closes module-owned realtime subscriptions. If the mutation throws or loader's ordinary cleanup and closes module-owned realtime subscriptions. If the mutation throws or
@@ -259,6 +268,16 @@ redirect target and performs no post-login navigation, so external and protocol-
have no redirect sink in this pass. See have no redirect sink in this pass. See
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md). [ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
Since [ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md) that URL
is a real page address: the API serves the Dashboard page at `/`, at `/settings` and `/account`, and
at `/<module-id>` for every discovered module that declares a Dashboard UI (enabled or not) — each
bare and with everything below it — with the same bytes, validator, and headers as `/`. A deep link
opened while signed out therefore shows the sign-in form at that address and opens its own page once
the person signs in — still with no return value and no redirect. Any other path — a module that is
not installed, a display-only module, a look-alike such as `/settings-old` — remains the sanitized
`404`. Module ids `account`, `api`, `assets`, `display`, and `settings` are reserved for the platform
and refused by discovery (`MODULE_ID_RESERVED`).
### The four anonymous screens ### The four anonymous screens
The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in, The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
@@ -1206,6 +1225,47 @@ platform-global; Ekklesia has no organization or tenant model to scope it to yet
Responses are per-principal, so `Cache-Control: no-store` and `Vary: Origin, Cookie`. Responses are per-principal, so `Cache-Control: no-store` and `Vary: Origin, Cookie`.
## Account
The signed-in person's own account
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)): who
they are signed in as, and their personal preferences, which follow them to every device.
| Route | Body | `200` answer |
| --- | --- | --- |
| `GET /api/v1/account` | none | `{"name":"Ada Lovelace","email":"[email protected]","preferences":{"appearance":"dark","motion":"system"}}` |
| `PATCH /api/v1/account/preferences` | `{"appearance":"light"}`, `{"motion":"reduce"}`, or both | `{"appearance":"light","motion":"system"}`, everything now in effect |
- **Self-only by construction.** Neither route accepts a user id in the path, query, or body. The
account is always the session's, so no request can read or change anyone else's.
- **Values.** `appearance` is one of `system`, `light`, or `dark`. `motion` is one of `system`,
`reduce`, or `allow`. A person who never chose gets `system` for both.
- **`PATCH` is partial.** It changes only the preferences it names and keeps the rest, so two devices
changing different preferences never overwrite each other. A body that is empty, names any other key,
or uses any other value is refused whole with `400 {"code":"INVALID_REQUEST"}`, and nothing is
written.
- **What it returns.** Name and email exactly as the account holds them, and nothing else about the
account: no image URL, verification state, timestamps, or user id.
- **Authority.** Both routes require `core.account.self.manage`, whose baseline is `authenticated`, so
every signed-in person may use them with no grant. An anonymous caller or a paired device gets
`403 {"code":"AUTHORIZATION_DENIED"}`. The `PATCH` also needs an exact trusted `Origin`
(`403 {"code":"UNTRUSTED_ORIGIN"}`) and a body within the Core mutation limit (`413`), both checked
before authorization.
- **Failures.** A person whose session outlived their account gets `404 {"code":"ACCOUNT_NOT_FOUND"}`.
A storage failure is a sanitized `503` (`ACCOUNT_READ_FAILED` or `ACCOUNT_WRITE_FAILED`).
- **Headers.** Responses are `Cache-Control: no-store` and `Vary: Origin, Cookie`.
**Storage.** One row per person in `ekklesia_core_user_preference`, with one typed, `CHECK`-constrained
column per preference. `null` means never chosen, and deleting the person deletes the row. It is created
by migration `core:0008_user_preferences`, which an operator applies with `pnpm migrations:apply`. Until
it is applied, the account routes answer a sanitized `503`, and the rest of the Dashboard keeps working.
**In the browser.** The Dashboard loads the account at bootstrap and applies its preferences through
the ADR 0060 theme controller. It keeps a two-word copy (`ekklesia.dashboard.appearance` in
`localStorage`) for one purpose: so `appearance.js` can apply the choice before the first paint. That
copy is never the source of truth. It is replaced by the account's answer on every load, cleared on
sign-out, and followed by other open tabs through the browser's `storage` event.
## Module UI inventory ## Module UI inventory
```text ```text
+10 -1
View File
@@ -112,6 +112,8 @@ list:
| `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. | | `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. |
| `core.modules.lifecycle.manage` | `restricted` | Read and change which installed modules are enabled. | | `core.modules.lifecycle.manage` | `restricted` | Read and change which installed modules are enabled. |
| `core.modules.configuration.manage` | `restricted` | Read and change an installed module's own configuration values. | | `core.modules.configuration.manage` | `restricted` | Read and change an installed module's own configuration values. |
| `core.devices.manage` | `restricted` | Approve display device pairing, list paired devices, and revoke a device. |
| `core.account.self.manage` | `authenticated` | Read your own account and read and change your own preferences. |
Every Core permission is a **platform** permission and can never be declared, gated, or influenced by Every Core permission is a **platform** permission and can never be declared, gated, or influenced by
a module — `core.*` is a reserved namespace (see below). `core.modules.lifecycle.manage` gates both a module — `core.*` is a reserved namespace (see below). `core.modules.lifecycle.manage` gates both
@@ -122,6 +124,13 @@ milder version of the write, because the lifecycle view is the only place a *dis
`core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority `core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority
inside any module. inside any module.
`core.account.self.manage` is the one Core permission every signed-in person holds by baseline
besides `core.modules.ui.read`: choosing your own appearance is not church administration
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)). It is
**self-only by construction** rather than by a check: the account routes take the account from the
session and accept no user id at all, so the permission, however it is held, confers nothing over
anyone else's account. A device never satisfies its `authenticated` baseline.
`core.modules.configuration.manage` is deliberately **separate from** the lifecycle permission `core.modules.configuration.manage` is deliberately **separate from** the lifecycle permission
([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)): deciding whether a ([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)): deciding whether a
module runs and deciding how it behaves are different authorities, and someone trusted to tune a module runs and deciding how it behaves are different authorities, and someone trusted to tune a
@@ -235,7 +244,7 @@ generic explicit-grant HTTP API, enforcement middleware, and now its own mounted
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the `pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced — same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
it never re-discovers modules itself. A successful composition reports a pass with a safe it never re-discovers modules itself. A successful composition reports a pass with a safe
declaration count (for example `5 permissions registered` with zero installed modules, since Core's declaration count (for example `7 permissions registered` with zero installed modules, since Core's
own declarations are always present); zero installed modules still composes successfully. If module own declarations are always present); zero installed modules still composes successfully. If module
discovery itself failed, the check is skipped, matching the existing schema/migration checks' discovery itself failed, the check is skipped, matching the existing schema/migration checks'
pattern; if discovery succeeded but composition failed, schema and migration checks still run pattern; if discovery succeeded but composition failed, schema and migration checks still run
+6 -6
View File
@@ -207,10 +207,10 @@ Two builds of the same commit produce byte-identical tarballs, and CI proves it
## Publishing the train ## Publishing the train
The registry is the Gitea npm registry under the `River_of_Glory_Church` owner: The registry is the Gitea npm registry under the `Zayin` owner:
```text ```text
https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ https://git.zayin.net/api/packages/Zayin/npm/
``` ```
| Who | May | | Who | May |
@@ -272,7 +272,7 @@ Evidence that one train was published, not a second package catalog, and never a
{ {
"publicationRecord": 1, "publicationRecord": 1,
"packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" }, "packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" },
"registry": "https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/", "registry": "https://git.zayin.net/api/packages/Zayin/npm/",
"finalTag": "latest", "finalTag": "latest",
"completedAt": "<ISO time>", "completedAt": "<ISO time>",
"packages": [ "packages": [
@@ -297,7 +297,7 @@ Evidence that one train was published, not a second package catalog, and never a
- **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by - **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by
the publish step. Nobody needs a token to read or install. the publish step. Nobody needs a token to read or install.
- **The publisher** is a dedicated account, member only of a `River_of_Glory_Church` team whose one - **The publisher** is a dedicated account, member only of a `Zayin` team whose one
permission is Packages: Write. Its token has only the `write:package` scope. Gitea's permission is Packages: Write. Its token has only the `write:package` scope. Gitea's
`write:package` also allows deleting packages; the tool never does, and nothing else uses the `write:package` also allows deleting packages; the tool never does, and nothing else uses the
token. token.
@@ -319,7 +319,7 @@ guarantee:
- **A published version is never deleted,** by anyone, for any reason short of a security incident. - **A published version is never deleted,** by anyone, for any reason short of a security incident.
- **A version number is never reused with other bytes.** A version found on the registry with other - **A version number is never reused with other bytes.** A version found on the registry with other
bytes is burned: investigate how it got there, and publish the train under a new version. bytes is burned: investigate how it got there, and publish the train under a new version.
- **No package cleanup rule** may be configured for the `River_of_Glory_Church` owner. - **No package cleanup rule** may be configured for the `Zayin` owner.
- **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests - **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests
check that. check that.
@@ -359,7 +359,7 @@ claim to be source that 0.1.2 never contained.
Before it, an administrator of the Gitea instance: Before it, an administrator of the Gitea instance:
1. creates the dedicated publisher account; 1. creates the dedicated publisher account;
2. creates a `River_of_Glory_Church` team, for example `package-publishers`, with only the Packages 2. creates a `Zayin` team, for example `package-publishers`, with only the Packages
unit at Write, and adds the account to it; unit at Write, and adds the account to it;
3. creates a token for the account with only `write:package`, and stores it as the repository 3. creates a token for the account with only `write:package`, and stores it as the repository
Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`; Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`;
+4 -2
View File
@@ -136,7 +136,7 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ Permission registry composed
6 permissions registered 7 permissions registered
○ Module lifecycle skipped ○ Module lifecycle skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Module configuration skipped ○ Module configuration skipped
@@ -146,7 +146,7 @@ Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
7 migrations · checksums valid 9 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0 core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180 core:0002_authorization_integrity · 634294731180
@@ -154,6 +154,8 @@ Data definitions
core:0004_module_lifecycle_state · c8b5d8b3d48b core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b core:0005_module_configuration · c8d57f24f84b
core:0006_device_identity · 1db97b7e3ac5 core:0006_device_identity · 1db97b7e3ac5
core:0007_verified_bootstrap_owners · 49013062f3e0
core:0008_user_preferences · fa9d4b0bb9d3
PostgreSQL PostgreSQL
✓ Database TLS verified ✓ Database TLS verified
+29 -1
View File
@@ -425,6 +425,28 @@ enforced by `requirePermission` against the composed platform permission registr
enforcement neither knows nor cares what a browser decided. Hiding a control is a usability enforcement neither knows nor cares what a browser decided. Hiding a control is a usability
improvement, never an access control. Treat any claim that a UI check protects anything as a bug. improvement, never an access control. Treat any claim that a UI check protects anything as a bug.
### A person's account is theirs alone, and the browser's copy of it is not authority
The account area
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md))
returns a signed-in person's name, email, and preferences, and lets them change their preferences. It
is **self-only by construction**: `GET /api/v1/account` and `PATCH /api/v1/account/preferences` take
the account from the session and accept no user id anywhere, so no request can name another person.
Both routes require `core.account.self.manage` (baseline `authenticated`), which no device satisfies.
The mutation requires an exact trusted `Origin` and a bounded body, like every Core mutation.
- **Nothing more leaves the server.** The account's image URL, verification state, timestamps, and
user id are never returned.
- **No avatar image is loaded.** The account's `image` is an arbitrary, unvetted URL, and fetching it
would disclose every Dashboard visit to whatever host it names. Initials are drawn instead.
- **Nothing about the account is in the HTML.** The Dashboard page is still one snapshot for everyone.
The pre-paint `appearance.js` is a same-origin external script, so the page still has no inline
script and a strict `script-src` needs no exception.
- **The browser keeps two words.** It stores the appearance and motion words under
`ekklesia.dashboard.appearance` for the next first paint. That copy holds nothing about the account,
is validated strictly before use, is replaced by the account's answer on every load, and is cleared
on sign-out. Tampering with it changes nothing but this browser's colours until the account answers.
## What this platform must not claim ## What this platform must not claim
Ekklesia today has **none** of the following, and no document, README, or marketing surface may Ekklesia today has **none** of the following, and no document, README, or marketing surface may
@@ -484,7 +506,13 @@ before it is installed: its digest, its archive safety (no links, special files,
escaping paths), every file's digest, and its identity and compatibility. The digest proves escaping paths), every file's digest, and its identity and compatibility. The digest proves
integrity, not who published the package; signing is not implemented. The verifier loads the backend integrity, not who published the package; signing is not implemented. The verifier loads the backend
in a short-lived process with an empty environment, so module code never sees a database URL or a in a short-lived process with an empty environment, so module code never sees a database URL or a
secret while it is being checked. secret while it is being checked. That load executes package code: `noexec` is retained on the
general `/tmp`, but native packages require the verifier's narrow, bounded execution scratch to be
executable. The one-shot hardened verifier container—not the scratch mount—is the isolation
boundary, and it is not claimed to be a perfect sandbox. Use the supported invocation in
[`deploy/README.md`](../../deploy/README.md#installed-modules), which also keeps the root filesystem
read-only, runs as the image's non-root user, disables networking and privilege escalation, and
drops Linux capabilities.
Two production defaults are relevant but are **not** by themselves a deployment guarantee: Two production defaults are relevant but are **not** by themselves a deployment guarantee:
+31 -1
View File
@@ -403,7 +403,37 @@ parser is exercised as the pure function it is, including that it names no desti
no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia
schema gained a token column and no identity code hashes or compares a token, that nothing schema gained a token column and no identity code hashes or compares a token, that nothing
interpolates a token, link, address, or body into a log line, that a device has no email, password, interpolates a token, link, address, or body into a log line, that a device has no email, password,
or verification concept, and that no browser source imports Better Auth or Core's mail. or verification concept, that no browser source imports Better Auth or Core's mail, and that the
shell's uses of browser storage are exactly two: the sidebar's expanded/collapsed mode
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)) and the
pre-paint appearance cache
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
`localStorage` is named once by the entry point and once, read-only, by the pre-paint script. There is
one write of each fixed key, one removal of the appearance key, and no storage API in any file that
holds the reset token.
The account area
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)) is
proven at each layer:
- `core/tests/account-preferences.test.ts` covers the closed preference model, its strict parser, the
table's typed columns, and that the migration's `CHECK` lists equal the model.
- `core/tests/account-store.integration.ts` uses real PostgreSQL. It proves defaults without writing a
row, saving each value, partial updates, isolation between two people, persistence across a new
connection, the database refusing an out-of-model value, and the cascade on delete.
- `apps/api/tests/account-routes.test.ts` covers the routes. Anonymous and device callers are refused,
only the session's account is ever read or written whatever the request names, there is no route
that addresses another person, and validation, `Origin`, body limit, failures, and CORS preflight
all behave as specified. It also holds Core's value sets equal to the design tokens'.
- `apps/api/tests/api-http.integration.ts` runs two real accounts over real HTTP sessions, with the
same isolation proven end to end.
- `surfaces/shell/tests/appearance-preference.test.ts` covers the cache, the pre-paint script (run in
the DOM), and the one handle over the ADR 0060 controller.
- `surfaces/shell/tests/account-appearance.test.tsx` covers the account control's identity, both
pages, live switching, saving, reverting on failure, reconciling with the account, cross-tab
following, sign-out forgetting, and the phone drawer.
- The deploy smoke's real Chromium checks Light and Dark rendering, reload persistence, an emulated OS
change, reduced motion, a module's Shadow DOM following the theme, and phone width.
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
injected in-memory provider and never touch the network. They prove the parts the protocol cannot: injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
+181 -6
View File
@@ -89,7 +89,7 @@ promises no API stability beyond the pre-1.0 SemVer policy. Only Ekklesia's rele
the registry in your repository's committed `.npmrc`; that is all the configuration there is: the registry in your repository's committed `.npmrc`; that is all the configuration there is:
```ini ```ini
@ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ @ekklesia:registry=https://git.zayin.net/api/packages/Zayin/npm/
``` ```
Then the module builds and packages like any other: Then the module builds and packages like any other:
@@ -296,9 +296,10 @@ The archive holds one directory named with your module id:
- **What it does not do.** A supplement never changes the licence the package declares. The - **What it does not do.** A supplement never changes the licence the package declares. The
supplements cover `@img/sharp-libvips-linux-x64` 1.3.3 and 1.3.4, the libvips binaries that supplements cover `@img/sharp-libvips-linux-x64` 1.3.3 and 1.3.4, the libvips binaries that
`sharp` 0.35.4 and 0.35.5 load; each matches only its own version and digest, and the packager's `sharp` 0.35.4 and 0.35.5 load; each matches only its own version and digest, and the packager's
native proof builds with sharp 0.35.5. They record upstream sources, but whether the LGPL and MPL native proof builds with sharp 0.35.5. A supplement identifies and verifies licence and notice
source obligations for those binaries are met is an open determination. A package that carries material only. Source availability for copyleft native code is a separate requirement, met by a
one is not yet treated as publicly distributable. corresponding-source artifact
([Distributing a native package publicly](#distributing-a-native-package-publicly)).
- **Deterministic.** The same packager version and the same built inputs produce the same archive, - **Deterministic.** The same packager version and the same built inputs produce the same archive,
byte for byte, wherever the checkout is. byte for byte, wherever the checkout is.
@@ -317,13 +318,113 @@ require exact agreement with Core. The legacy facade has not been removed.
`node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an `node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an
Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without
installing it. Pass `--extract-to <new directory>` to receive the verified module directory. installing it. Pass `--extract-to <new directory>` to receive the verified module directory.
[deploy/README.md](../../deploy/README.md#installed-modules) shows the whole installation. [deploy/README.md](../../deploy/README.md#installed-modules) shows the supported hardened container
invocation; use it rather than inventing mount flags. Backend verification executes package code.
Its container is therefore the isolation boundary: general `/tmp` remains `noexec`, while a narrow,
bounded, ephemeral verifier scratch tmpfs permits execution so native addons can load.
The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An
installed package normally has no source, so the platform cannot check it the same way. Your installed package normally has no source, so the platform cannot check it the same way. Your
module's own CI is where its boundary rules, its production dependencies, and its behaviour inside module's own CI is where its boundary rules, its production dependencies, and its behaviour inside
Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)). Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)).
#### Distributing a native package publicly
Building and testing a native package needs nothing beyond the packager. **Distributing it
publicly** does, when it redistributes native code under the GNU LGPL, the MPL, or a similar licence
with source obligations
([ADR 0055](../architecture/decisions/0055-corresponding-source-artifacts.md)).
- **Covered dependencies.** A native dependency is covered when its declared licence, or any
component its reviewed supplement records inside it, carries source obligations. libvips is one:
it declares `LGPL-3.0-or-later`, and cairo and several Rust crates inside it are under the MPL.
- **The policy.** A package with covered dependencies may be distributed publicly only together with
a corresponding-source artifact. The artifact holds the exact source, patches, build definitions,
and rebuild and relink instructions for those dependencies, as bytes rather than links. If the
artifact cannot be produced and verified, the package is not approved for public distribution.
This is Ekklesia's project compliance policy, not legal advice or certification. A package that
passes the gate meets Ekklesia's redistribution policy, and nothing more is claimed.
- **Source recipes.** For each covered dependency version, a reviewed recipe is checked in beside
the supplements, under `packages/module-pack/corresponding-sources/<package>/<version>/`. It holds
`recipe.json` and its documents, notably `BUILDING.md`. The recipe names the exact package (name,
version, content digest) and the exact supplement it was prepared with. It lists every component
with its role (`compiled`, `build-input`, or `build-definition`), licence, licence material, and
relationship to the binary. Every source file and patch is recorded by origin, size, and SHA-256,
and every exclusion has a reason. Like supplements, recipes are reviewed data, and the mechanism
knows no particular library.
Releasing a native package publicly takes three steps after packaging:
```bash
# 1. Acquisition: the only step that uses the network. It fetches each recorded file once into a
# cache keyed by SHA-256, refusing any response that differs from the recipe.
pnpm exec ekklesia-corresponding-source acquire --package ../packages/<id>-<version>-linux-x64-glibc.tar.gz \
--cache ~/.cache/ekklesia/corresponding-sources
# 2. Construction: offline. Every byte is verified against the recipe before anything is written.
pnpm exec ekklesia-corresponding-source build --package ../packages/<id>-<version>-linux-x64-glibc.tar.gz \
--cache ~/.cache/ekklesia/corresponding-sources --out ../packages
# 3. The public-distribution gate. Exit status 0 means approved.
pnpm exec ekklesia-corresponding-source verify --package ../packages/<id>-<version>-linux-x64-glibc.tar.gz \
--source ../packages/<id>-<version>-linux-x64-glibc-corresponding-source.tar.gz \
--metadata ../packages/<id>-<version>-linux-x64-glibc.redistribution.json
```
Construction writes four sibling release artifacts beside the package. The source archive never goes
inside the Module Package, and `THIRD_PARTY_NOTICES` is unchanged.
```text
<id>-<version>-linux-x64-glibc.tar.gz the Module Package (packager)
<id>-<version>-linux-x64-glibc.tar.gz.sha256
<id>-<version>-linux-x64-glibc-corresponding-source.tar.gz the corresponding source
<id>-<version>-linux-x64-glibc-corresponding-source.tar.gz.sha256
<id>-<version>-linux-x64-glibc.redistribution.json binary ↔ source, by digest
<id>-<version>-linux-x64-glibc.source-notice.txt the release notice
```
- **The source archive** is deterministic: sorted entries, fixed modes, ownership, and timestamps,
and no machine-specific path. The same package and recipe produce the same bytes in any directory.
- `corresponding-source.json` records every file with its SHA-256, and `SHA256SUMS` repeats them.
- Each covered dependency has its own `dependencies/<package>/<version>/` directory, with
`BUILDING.md`, the recipe, `PROVENANCE.md`, the supplement, and `sources/` and `patches/`.
- **The redistribution metadata** answers "which exact source corresponds to this exact binary?"
from digests. It records:
- the package file and its SHA-256, the module identity, and the target;
- each covered dependency's version and content digest, and the supplement and recipe it was
assembled from;
- the source archive's file name and SHA-256, and the SHA-256 of its manifest.
- **The release notice** must be shown alongside the binary wherever it is offered. It states that
the release includes third-party software under licences including the GNU LGPL and the MPL, and
that corresponding source and build and relink materials for the covered native dependencies are
in the accompanying artifact. It names that artifact by its exact file name and SHA-256.
- **Fail-closed.** The gate refuses a package when:
- a covered dependency has no recipe, or a recipe or supplement does not match its exact bytes;
- the source archive is missing, non-canonical, or differs from its manifest;
- the archive holds a file the manifest does not record, or a generated file names a local path;
- the metadata or the notice does not name these exact files.
It also refuses bundled (not copied) copyleft code, which this mechanism does not cover. A package
with no covered native code is approved without an artifact.
- **Retention.** Keep a release's source artifact for as long as its binary release is distributed
or supported. Never delete one automatically while the binary remains available. Never replace its
bytes under the same name. A correction is a new release, or an explicitly versioned compliance
artifact.
**Available today:** a recipe for `@img/sharp-libvips-linux-x64` 1.3.4, used by sharp 0.35.5. Its
artifact is about 200 MB: 27 library archives, 5 patches, 353 Rust crates, and the Rust standard
library source of the recorded toolchain. Following its `BUILDING.md` rebuilds
`libvips-cpp.so.8.18.7` from that source alone. libvips 1.3.3 (sharp 0.35.4) has a supplement but no
recipe, so a package with it builds and tests as before but is not approved for public distribution.
`pnpm test:corresponding-source` runs the whole proof for the native proof module: acquisition into a
verified cache, two byte-identical builds, the gate, and sharp running from the package.
**Adding a native dependency** follows the same path as libvips:
- curate and review its supplement (ADR 0052);
- curate and review its source recipe and `BUILDING.md`, recording every source input the
dependency's build fetches;
- prove both with the native packaging tests and `pnpm test:corresponding-source`.
### License ### License
Ekklesia is licensed under the GNU Affero General Public License, version 3 or (at your option) any Ekklesia is licensed under the GNU Affero General Public License, version 3 or (at your option) any
@@ -434,7 +535,20 @@ without executing module code:
} }
``` ```
Module, capability, and permission identifiers are lowercase and namespaced. A provided capability Module, capability, and permission identifiers are lowercase and namespaced. A module with a
Dashboard UI is addressed at `/<module-id>` — `/calendar` for this one
([ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)) — in the same
top-level URL space as the platform's own pages, so a few ids are **reserved** and Core refuses a
module that declares one (`MODULE_ID_RESERVED`, reported by discovery, `pnpm modules:validate`, and
package verification): `account`, `api`, `assets`, `display`, and `settings` at the top level, and
`branding`, `discover`, `displays`, `general`, and `system`, the platform's own Settings sections. Your
module id is a **stable URL identifier**: if your module declares `configuration`, its settings live at
`/settings/<module-id>` — `/settings/calendar` for this one — as a first-class Settings destination,
visible to whoever holds `core.modules.configuration.manage`
([ADR 0061](../architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)).
Module lifecycle — installing, turning on and off, updating — is the platform's Settings → Discover, not
yours to build. Do not build a second settings system inside your module; link to your Settings page
instead once the module navigation contract can carry a return link. A provided capability
has its own exact semantic version because its public contract can evolve independently from the has its own exact semantic version because its public contract can evolve independently from the
module release. Capability dependencies use semantic-version ranges. Optional means the provider module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible. may be absent; if it is installed, its capability version must be compatible.
@@ -470,6 +584,17 @@ module's own HTTP routes, and creating relationship-derived grants automatically
own business logic, are both still later work; this only makes a declared permission *usable* own business logic, are both still later work; this only makes a declared permission *usable*
through Core's existing, generic surface. through Core's existing, generic surface.
### Icons in your module's UI
Lucide is Ekklesia's icon vocabulary
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). If your
UI needs icons, depend on `lucide-react` and import each icon by name, rather than drawing your own
SVGs (module `.svg` presentation assets are rejected by `pnpm boundaries` anyway). Treat icons as
decorative: give every control a text name, visible or visually hidden, and keep the icon
`aria-hidden`. The Dashboard's navigation currently shows every module with one generic icon; a
module will declare its own navigation icon through the module navigation contract (manifest v9), not
yet.
### Your module's display name ### Your module's display name
`name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees `name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees
@@ -778,10 +903,60 @@ The host installs the default token values at its own root, and CSS custom prope
through an isolated artifact's boundary — so the tokens are simply there, with nothing to import at through an isolated artifact's boundary — so the tokens are simply there, with nothing to import at
run time and nothing for the host to hand you. run time and nothing for the host to hand you.
**Light, Dark, and System come with the tokens**
([ADR 0060](../architecture/decisions/0060-light-dark-system-theme-and-motion.md)). The host decides
the appearance, and the token values change under you — live when the person's system switches —
inside your isolated root too. Your UI is themed for free as long as every colour it uses is a token:
never assume a white background, black text, or one brand colour, and never set the appearance
yourself. `color-scheme` follows as well, so native inputs and scrollbars match. Colours that
represent *content* rather than interface — a preview of what a physical screen shows, an uploaded
image — are yours to keep literal.
**Animate for a motion token, not a literal duration.** `motion.duration-short` (transitions) and
`motion.duration-ambient` (a gentle, continuous indication) are zero when motion is reduced — by the
system or by the person's preference — so an animation that uses them needs no
`prefers-reduced-motion` rule of its own, and honours an explicit "allow motion" that such a rule
would ignore:
```css
.calendar-panel { transition: opacity var(--ekklesia-motion-duration-short) ease; }
```
**Round corners with `radius.sm`, `radius.md`, or `radius.lg`.** They scale together from one
`radius.scale` value, so a church's chosen roundness reaches your UI without you changing anything.
Every other shared package stays rejected inside a module: `@ekklesia/api-client`, Every other shared package stays rejected inside a module: `@ekklesia/api-client`,
`@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to `@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
import, and `pnpm boundaries` says so. import, and `pnpm boundaries` says so.
### Confirmations, toasts, errors, loading, and Back
`@ekklesia/ui` also carries the interactions DESIGN.md fixes for every screen
([ADR 0059](../architecture/decisions/0059-shared-interaction-primitives.md)); use these rather than
building your own:
- **`ConfirmationDialog`** for any action worth confirming. You choose the severity: `tone` stays
`default` for a simple confirmation, becomes `"destructive"` for an action that matters, and adds
`requiredPhrase` (`"DELETE"`, or the item's own name) only for the most serious ones. Name the
action for what it does (`confirmLabel="Delete display"`), name the resource and the consequence in
`description`, and set `busy` while the action runs.
- **`ToastProvider`** once near the top of your tree, and `useToasts().show({ tone, title })` where
something worth confirming happened. Errors stay until dismissed; everything else times out once it
has been read.
- **`ErrorState`** with a plain `title` and `description`, recovery `actions`, and — collapsed —
`details` and a `reference`. Pass a sanitized code or message as `details`, never a caught exception.
- **`SkeletonGroup`** labelled with what is loading, containing `Skeleton`s in the shape of the content.
- **`PageHeader`** with `back` on a nested page. Your module decides where its own Back goes; a link
into the Dashboard's Settings will carry a validated return target once the module navigation
contract exists.
Never call `window.alert`, `window.confirm`, or `window.prompt`. Ekklesia's own lint refuses them;
enable the same rule in your module's `biome.json`:
```json
{ "linter": { "rules": { "suspicious": { "noAlert": "error" } } } }
```
### Styling: a stylesheet compiled into your artifact ### Styling: a stylesheet compiled into your artifact
Author plain `.css` beside your UI source, import it, and declare it Author plain `.css` beside your UI source, import it, and declare it
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ekklesia", "name": "ekklesia",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -43,6 +43,7 @@
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh", "test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
"test:root": "vitest run --config vitest.config.ts", "test:root": "vitest run --config vitest.config.ts",
"test:package-release": "bash scripts/test-package-release.sh", "test:package-release": "bash scripts/test-package-release.sh",
"test:corresponding-source": "bash scripts/test-corresponding-source.sh",
"test:package-publication": "bash scripts/test-package-publication.sh", "test:package-publication": "bash scripts/test-package-publication.sh",
"test:watch": "vitest", "test:watch": "vitest",
"typecheck": "turbo run typecheck typecheck:root", "typecheck": "turbo run typecheck typecheck:root",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/contracts", "name": "@ekklesia/contracts",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+22
View File
@@ -87,6 +87,28 @@ export class ModuleStorageError extends Error {
} }
} }
/**
* Whether `value` is a module-storage error, judged by its stable public shape rather than its class.
*
* An installed module bundles its own copy of the contracts package (ADR 0049), so an error created
* by the host's storage provider does not share class identity with the module's
* `ModuleStorageError`. `instanceof ModuleStorageError` is therefore not a valid check across the
* module boundary.
*/
export function isModuleStorageError(
value: unknown,
): value is Pick<ModuleStorageError, "code" | "key" | "message" | "name"> {
if (!(value instanceof Error) || value.name !== "ModuleStorageError") {
return false;
}
const { code, key } = value as { code?: unknown; key?: unknown };
return (
typeof code === "string" &&
(MODULE_STORAGE_ERROR_CODES as readonly string[]).includes(code) &&
(key === undefined || typeof key === "string")
);
}
/** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes, /** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes,
* not characters, because that is what the wire format bounds. */ * not characters, because that is what the wire format bounds. */
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024; export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/design-tokens", "name": "@ekklesia/design-tokens",
"version": "0.2.1", "version": "0.2.5",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+162
View File
@@ -0,0 +1,162 @@
import {
DESIGN_TOKEN_APPEARANCE_ATTRIBUTE,
DESIGN_TOKEN_MOTION_ATTRIBUTE,
type DesignTokenColorScheme,
} from "./token.js";
/**
* A person's appearance preference (DESIGN.md §3.2, ADR 0060). `system` follows the operating
* system's light or dark setting, live; it is the default.
*/
export const EKKLESIA_APPEARANCES = Object.freeze(["system", "light", "dark"] as const);
export type EkklesiaAppearance = (typeof EKKLESIA_APPEARANCES)[number];
/**
* A person's motion preference (DESIGN.md §3.3, ADR 0060). `system` follows
* `prefers-reduced-motion`; `reduce` always reduces; `allow` keeps the platform's subtle motion even
* when the system asks for less.
*/
export const EKKLESIA_MOTION_PREFERENCES = Object.freeze(["system", "reduce", "allow"] as const);
export type EkklesiaMotionPreference = (typeof EKKLESIA_MOTION_PREFERENCES)[number];
export const EKKLESIA_DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";
export const EKKLESIA_REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
/** Reads an untrusted stored or received value; anything unrecognised is the default, `system`. */
export function parseEkklesiaAppearance(value: unknown): EkklesiaAppearance {
return EKKLESIA_APPEARANCES.includes(value as EkklesiaAppearance)
? (value as EkklesiaAppearance)
: "system";
}
/** Reads an untrusted stored or received value; anything unrecognised is the default, `system`. */
export function parseEkklesiaMotionPreference(value: unknown): EkklesiaMotionPreference {
return EKKLESIA_MOTION_PREFERENCES.includes(value as EkklesiaMotionPreference)
? (value as EkklesiaMotionPreference)
: "system";
}
/** The scheme an appearance shows, given whether the system currently prefers dark. */
export function resolveEkklesiaColorScheme(
appearance: EkklesiaAppearance,
systemPrefersDark: boolean,
): DesignTokenColorScheme {
if (appearance === "system") {
return systemPrefersDark ? "dark" : "light";
}
return appearance;
}
/** Whether motion is reduced, given the preference and whether the system currently asks for less. */
export function resolveEkklesiaReducedMotion(
preference: EkklesiaMotionPreference,
systemPrefersReduced: boolean,
): boolean {
return preference === "system" ? systemPrefersReduced : preference === "reduce";
}
/** The element a host themes: its document's root element, or an isolated root's host element. */
export interface EkklesiaThemeTarget {
setAttribute(name: string, value: string): void;
}
/** A media query list, structurally: what `window.matchMedia` returns. */
export interface EkklesiaMediaQueryList {
readonly matches: boolean;
addEventListener(type: "change", listener: () => void): void;
removeEventListener(type: "change", listener: () => void): void;
}
export interface EkklesiaThemeControllerOptions {
readonly target: EkklesiaThemeTarget;
/** The browser's `matchMedia`, handed in by the host. Without one, the system is taken to prefer
* light and full motion. */
readonly matchMedia?: (query: string) => EkklesiaMediaQueryList;
readonly appearance?: EkklesiaAppearance;
readonly motion?: EkklesiaMotionPreference;
}
/**
* The host's handle on its theme (ADR 0060).
*
* The **stylesheet** does the work: installed tokens resolve light, dark, and reduced motion in CSS
* from the system's media features and two attributes on the theme root, so a change of OS setting
* restyles everything at once — every isolated module root included, since custom properties and
* `color-scheme` inherit — with no script and no reload. This controller only sets those attributes,
* and answers what is currently in effect for code that needs to know.
*/
export interface EkklesiaThemeController {
appearance(): EkklesiaAppearance;
motion(): EkklesiaMotionPreference;
/** The scheme on screen now: the explicit appearance, or the system's while it is `system`. */
colorScheme(): DesignTokenColorScheme;
/** Whether motion is reduced now. */
reducedMotion(): boolean;
setAppearance(appearance: EkklesiaAppearance): void;
setMotion(motion: EkklesiaMotionPreference): void;
/** Notifies after a preference changes, and after the system's setting changes. */
subscribe(listener: () => void): () => void;
}
/**
* Applies a theme to its target **immediately** — before anything renders, which is what keeps a
* persisted explicit choice from flashing the other scheme first — and returns its controller.
* Preferences are taken through {@link parseEkklesiaAppearance} and
* {@link parseEkklesiaMotionPreference}, so an untrusted stored value can be passed straight in.
*/
export function createEkklesiaThemeController(
options: EkklesiaThemeControllerOptions,
): EkklesiaThemeController {
let appearance = parseEkklesiaAppearance(options.appearance);
let motion = parseEkklesiaMotionPreference(options.motion);
const darkQuery = options.matchMedia?.(EKKLESIA_DARK_SCHEME_QUERY);
const motionQuery = options.matchMedia?.(EKKLESIA_REDUCED_MOTION_QUERY);
const listeners = new Set<() => void>();
const notify = (): void => {
for (const listener of [...listeners]) {
listener();
}
};
options.target.setAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, appearance);
options.target.setAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE, motion);
return {
appearance: () => appearance,
colorScheme: () => resolveEkklesiaColorScheme(appearance, darkQuery?.matches ?? false),
motion: () => motion,
reducedMotion: () => resolveEkklesiaReducedMotion(motion, motionQuery?.matches ?? false),
setAppearance(next) {
const parsed = parseEkklesiaAppearance(next);
if (parsed === appearance) {
return;
}
appearance = parsed;
options.target.setAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, appearance);
notify();
},
setMotion(next) {
const parsed = parseEkklesiaMotionPreference(next);
if (parsed === motion) {
return;
}
motion = parsed;
options.target.setAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE, motion);
notify();
},
subscribe(listener) {
if (listeners.size === 0) {
darkQuery?.addEventListener("change", notify);
motionQuery?.addEventListener("change", notify);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
darkQuery?.removeEventListener("change", notify);
motionQuery?.removeEventListener("change", notify);
}
};
},
};
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./appearance.js";
export * from "./install.js"; export * from "./install.js";
export * from "./registry.js"; export * from "./registry.js";
export * from "./token.js"; export * from "./token.js";

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