Author SHA1 Message Date
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
116 changed files with 9029 additions and 461 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ concurrency:
env:
# The only registry-specific setting: where the train is published.
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
jobs:
package-release:
+64 -15
View File
@@ -6,27 +6,60 @@ contents, because restated details drift.
## What this repository is
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
monorepo:
Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built
as a strictly layered pnpm/TypeScript monorepo:
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
feature module, a surface, or an app.
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
packages. They own their own data and migrations, declare capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
primary and full-viewport display artifacts, authored in React against the same role-neutral
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
imports a feature module, a surface, or an app.
- **Modules** are independently versioned product packages. First-party modules live in **their own
repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
(ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
Module Database ABI v1 (ADR 0050).
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
presentation concern a module does not. They consume public API/client contracts only, never Core
or module internals directly.
presentation concern a module does not. The shell **hosts** module UI through the generic loader;
it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only.
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
documented. Read `docs/architecture/repository-boundaries.md` and
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
## Product direction lives beside this repository
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into
this repository to make them available. They are authoritative for product direction: read the
relevant stages and sections before designing a change. If this repository's documentation disagrees
with them, flag the conflict, correct the repository-local documentation where appropriate, and never
silently follow the stale text. Architectural decisions still change only through ADRs (below).
Rules from those documents that every change here must respect:
- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and
devices, accounts and identity, roles and authorization, branding, mail, push, storage providers,
and module installation/lifecycle — belong to the platform, and modules consume them.
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from
installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may.
Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy.
- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light,
dark, and system modes. Normal UI colours come only from the shared design tokens
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
in product UI. Destructive actions use in-product confirmation proportional to their impact.
- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard
sidebar's *Display devices* panel) and Signage's own display roster are a transitional
implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not
build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell
redesign early, and keep existing paired displays working across any change.
## ADRs are authoritative
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
@@ -43,6 +76,7 @@ At minimum, read the current versions of:
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
- `README.md` (current platform status — what is and is not implemented)
- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above)
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
- `CONTRIBUTING.md`
@@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
## Working conventions
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
change branch-protection settings.
- The canonical repositories are on the church Gitea under the `Zayin` organization:
`ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
and branch from `origin/main`, never from whatever is checked out.
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
merge your own PR, and never change branch-protection settings.
- Never create or push a release tag. Release tags belong to the human maintainer.
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
platform logic into a module to avoid making the correct Core change.
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
burying them in an implementation.
- Add or update tests when you change a contract or architectural rule. New behavior needs new
@@ -66,6 +108,13 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
- Never run a database migration automatically (on import, on startup, in a test that isn't
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
an explicit, operator-invoked action.
- Report what you find. A defect, flaky test, documentation/ADR conflict, or CI problem outside the
task at hand gets an issue in the repository it belongs to, as soon as you find it. Search for an
existing issue first; something fixed in the same PR goes in that PR's description instead. Be
descriptive: what you saw, the evidence (run and job links, commit, file and line), how to
reproduce it, the impact, and options, and never a secret. Label it `found-by-claude` (an agent
that cannot apply labels ends the body with a line saying so), leave it unassigned, and list what
you filed when you report back.
## Canonical verification commands
+33 -1
View File
@@ -822,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
public Website remain separate future designs.
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing
(since added; see the Stage 1 paragraph below),
and no module-management, settings, or storage UI — module enablement and configuration are an API and
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
@@ -905,6 +906,37 @@ the module's responsibility and do not exist yet.**
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
Stage 1 begins with **real Dashboard URLs**
([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The
Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own
address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark,
and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages.
The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout
each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves
the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and
discovery refuses a module that uses one. An installed module that is disabled shows a host-owned
*This page isn't available* rather than loading anything. A module's own
sub-pages, Settings and Account pages, and returning to the workflow a Settings link came from are
later Stage 1 work.
The Dashboard's **shell** follows DESIGN.md §4
([ADR 0058](docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)): a
collapsible left sidebar — icons and labels expanded, icons with accessible tooltips collapsed, the
choice remembered in this browser — with the Overview first, the server's groups and modules below it,
and an account menu anchored at the bottom (Sign out, for now). There is no top bar. On a phone the
same navigation opens in a modal drawer with focus trapped inside it, Escape and the backdrop closing
it, and focus returned to the menu button. Lucide is Ekklesia's icon vocabulary; until modules can
declare their own icon (manifest v9), every module shows one generic icon. Church branding in the
sidebar's identity slot is still to come.
`@ekklesia/ui` now carries the interactions every later screen shares
([ADR 0059](docs/architecture/decisions/0059-shared-interaction-primitives.md)): a product-owned
`ConfirmationDialog` (simple, destructive, or typed-phrase, chosen by the caller), a per-tree
top-center toast system, `ErrorState` with collapsed technical detail and remedies, skeleton loading,
and `PageHeader` with a Back action that returns only to a validated Dashboard page. Every dialog
traps and restores focus, and `pnpm lint` refuses `window.alert`, `confirm`, and `prompt`
repository-wide.
## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/api",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
+19 -5
View File
@@ -31,7 +31,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
import { createDashboardRoutes } from "./dashboard-routes.js";
import { createDeviceRoutes } from "./device-routes.js";
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js";
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
@@ -363,10 +363,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
// own entirely. See `./health-routes.js`.
app.route("/", createHealthRoutes(options.health));
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
// `./dashboard-host-routes.js`.
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
// The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the
// page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
// `/<module-id>`. They grant no authority and resolve no principal; the page asks
// `/api/v1/principal` itself. See `./dashboard-host-routes.js`.
app.route(
"/",
createDashboardHostRoutes(options.dashboardHostAssets, {
modulePages: options.moduleLifecycle.snapshot.discoveredModules
.filter((module) => module.entrypoints.ui !== undefined)
.map((module) => module.manifest.id),
}),
);
app.route(
"/",
@@ -407,6 +415,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
registry: options.authorization.registry,
runtime,
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
...(options.devices
? {
renewDeviceCredential: (c: Context) =>
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
}
: {}),
});
if (options.devices) {
+65 -15
View File
@@ -1,13 +1,19 @@
import { moduleIdSchema } from "@ekklesia/contracts";
import { isReservedModuleId } from "@ekklesia/core/modules";
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
import { Hono } from "hono";
import { etagMatches } from "./conditional-request.js";
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
/**
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
* `/api/v1/modules/<module-id>`.
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
* serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
* this server and the shell's router read one list), and at each installed module's `/<module-id>`.
* Every one of those first segments is either a reserved platform name or a module id Core accepted,
* and Core refuses a module id that is a reserved name, so no module page can shadow `/api`,
* `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display
* host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still
* lands on `/` with a query (ADR 0038).
*/
export const DASHBOARD_HOST_PAGE_PATH = "/";
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
@@ -85,29 +91,73 @@ function fixedAsset(
});
}
export interface DashboardHostRouteOptions {
/**
* The id of every module this process discovered that declares a Dashboard UI — enabled or not,
* since a disabled module's bookmark should open the Dashboard and say the module is unavailable
* rather than fail as an unknown address. Fixed at startup, like the rest of the module set.
*/
readonly modulePages: readonly string[];
}
/** Each module page must be a real module id that names no platform namespace, exactly once.
* Discovery already guarantees this; the routes refuse to compose rather than trust it. */
function validatedModulePages(modulePages: readonly string[]): readonly string[] {
const seen = new Set<string>();
for (const moduleId of modulePages) {
if (
!moduleIdSchema.safeParse(moduleId).success ||
isReservedModuleId(moduleId) ||
DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) ||
seen.has(moduleId)
) {
throw new DashboardHostConfigurationError();
}
seen.add(moduleId);
}
return [...seen];
}
/**
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
* default same-origin shape.
*
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
* the sign-in form until `/api/v1/principal` says otherwise.
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
* from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
* platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
* module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
* server change later) — always with the **same** bytes and the same validator, because the path
* selects a page inside the shell rather than a file here; that is what lets a refreshed or
* bookmarked deep link stay where it was.
*
* A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not
* installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does
* `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall through to the
* application's ordinary sanitized `404`. Like the display host's code, these bytes grant no
* authority: the page renders the sign-in form until `/api/v1/principal` says otherwise.
*/
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
export function createDashboardHostRoutes(
assets: DashboardHostAssets,
options: DashboardHostRouteOptions,
): Hono {
const snapshot = snapshotAssets(assets);
const modulePages = validatedModulePages(options.modulePages);
const routes = new Hono();
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
const page = (ifNoneMatch: string | undefined): Response =>
fixedAsset(
c.req.header("if-none-match"),
ifNoneMatch,
snapshot.html,
snapshot.htmlEtag,
"text/html; charset=utf-8",
DASHBOARD_PAGE_SECURITY_HEADERS,
),
);
);
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
routes.get(`/${name}`, (c) => page(c.req.header("if-none-match")));
routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match")));
}
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
fixedAsset(
+22 -1
View File
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
*/
function writeDeviceCookie(
c: Context,
options: DeviceRoutesOptions,
options: Pick<DeviceRoutesOptions, "secureCookies">,
name: string,
value: string,
maxAgeSeconds: number,
@@ -106,6 +106,27 @@ function writeDeviceCookie(
});
}
/**
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
*
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
*/
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
if (credential === undefined || credential.length === 0) {
return;
}
writeDeviceCookie(
c,
{ secureCookies },
DEVICE_SESSION_COOKIE,
credential,
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
);
}
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
const routes = new HonoApp();
const now = options.now ?? ((): Date => new Date());
+8
View File
@@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
* surface-owned files before binding and supplies their frozen contents here. */
readonly assets?: DisplayHostAssets;
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
* deployment has device support; called only for a request that resolved to a device. */
readonly renewDeviceCredential?: (c: Context) => void;
}
/** A composition error, detected before any display route is registered. Carries no module id,
@@ -199,6 +202,11 @@ function mountDisplay(
if (!viewer) {
return displayFailure(c);
}
// Every screen asks for this on start, on reconnect, and periodically while running, so it
// is where an in-use credential's browser expiry is pushed forward.
if (principal.kind === "device") {
options.renewDeviceCredential?.(c);
}
return c.json(await describeDisplay(options, principal, display, viewer), 200);
} catch {
return displayFailure(c);
+26 -3
View File
@@ -208,6 +208,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
}
}
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
const SHUTDOWN_SETTLE_MS = 3_000;
/**
* The connections left on `databaseName` once a shutdown has had a moment to land.
*
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
*/
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
for (;;) {
const count = await activeConnectionCount(databaseName);
if (count === 0 || Date.now() >= deadline) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
@@ -1349,7 +1372,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
await running.shutdown();
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
await dropDatabase(database.name);
@@ -2230,7 +2253,7 @@ describe.sequential("live API — platform owner and delegated grant management"
it("shuts down cleanly, closing the one shared pool", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
await dropDatabase(database.name);
});
@@ -2837,7 +2860,7 @@ describe.sequential("live API — installed module permission end-to-end", () =>
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
// Exactly one stop, matching the exactly-one register/start already asserted above.
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
+217 -3
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import {
ANONYMOUS_PRINCIPAL,
type ModuleRouteHandler,
parseModuleManifest,
parsePermissionId,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
@@ -13,9 +14,13 @@ import {
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
EMPTY_MODULE_REALTIME_REGISTRY,
isReservedModuleId,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleLifecycleConfiguration,
RESERVED_MODULE_IDS,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
@@ -33,11 +38,23 @@ import {
DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
DashboardHostConfigurationError,
createDashboardHostRoutes,
} from "../src/dashboard-host-routes.js";
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
import {
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
MODULE_DISPLAY_PAGE_PREFIX,
} from "../src/module-display-path.js";
import {
DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_PATH,
} from "../src/module-display-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
@@ -78,9 +95,40 @@ function fakeModuleServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id
* and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */
function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: {
server: "./dist/server/index.js",
...(options.ui ? { ui: "./dist/ui/index.js" } : {}),
},
id,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
version: "0.1.0",
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: {
server: `/modules/${id}/dist/server/index.js`,
...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}),
},
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
function buildApp(
options: {
readonly assets?: DashboardHostAssets;
readonly configuration?: ModuleLifecycleConfiguration;
readonly discovered?: readonly DiscoveredModule[];
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
} = {},
) {
@@ -100,7 +148,10 @@ function buildApp(
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: fakeModuleServices(),
moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
@@ -193,6 +244,169 @@ describe("GET / (the Dashboard page)", () => {
});
});
describe("Dashboard deep links (ADR 0057)", () => {
const installed = () => [
discoveredModule("signage", { ui: true }),
discoveredModule("calendar", { ui: true }),
discoveredModule("retired", { ui: true }),
// A display-only module has no Dashboard page (ADR 0033).
discoveredModule("lobby-screen", { ui: false }),
];
// Installed but disabled: its address still opens the Dashboard, which says it is unavailable.
const configuration = { modules: [{ enabled: false, moduleId: "retired" }] };
const deepLinks = [
"/settings",
"/settings/",
"/settings/displays/add",
"/account",
"/account/appearance",
"/signage",
"/signage/",
// Reserved for the module's own pages, which the shell will interpret (ADR 0057).
"/signage/timelines",
"/signage/timelines/a1b2",
"/calendar",
"/retired",
];
it("serves the shell's own platform pages, from the shell's own list", () => {
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
});
it("serves the identical page, validator, and security headers at every Dashboard page, so a refresh stays put", async () => {
const { app } = buildApp({ configuration, discovered: installed() });
for (const path of deepLinks) {
const response = await app.request(path);
expect(response.status, path).toBe(200);
expect(await response.text(), path).toBe(PAGE_HTML);
expect(response.headers.get("content-type"), path).toBe("text/html; charset=utf-8");
expect(response.headers.get("cache-control"), path).toBe("no-cache");
expect(response.headers.get("etag"), path).toBe(PAGE_ETAG);
expectPageSecurityHeaders(response);
}
expect(readFileMock).not.toHaveBeenCalled();
});
it("revalidates a deep link with the page's one validator", async () => {
const { app } = buildApp({ discovered: installed() });
for (const path of ["/settings/displays", "/signage"]) {
const response = await app.request(path, { headers: { "If-None-Match": PAGE_ETAG } });
expect(response.status, path).toBe(304);
expect(await response.text(), path).toBe("");
expectPageSecurityHeaders(response);
}
});
it("never turns an arbitrary top-level path into a Dashboard page", async () => {
const { app } = buildApp({ configuration, discovered: installed() });
for (const path of [
"/definitely-not-a-real-module",
"/definitely-not-a-real-module/timelines",
// Installed, but with no Dashboard UI.
"/lobby-screen",
// Look-alikes of real pages.
"/signages",
"/signage-old",
"/Signage",
"/settings-old",
"/accounts",
// The former implementation prefix is not a namespace at all.
"/modules",
"/modules/signage",
]) {
await expectSanitizedNotFound(await app.request(path), path);
}
});
it("serves a module's page only while that module is installed", async () => {
const { app } = buildApp({ discovered: [] });
await expectSanitizedNotFound(await app.request("/signage"), "/signage");
expect((await app.request("/settings")).status).toBe(200);
});
it("serves the page and never the script, whatever a deep link's last segment looks like", async () => {
const { app } = buildApp({ discovered: installed() });
for (const path of [
"/signage/app.js",
"/settings/assets/ekklesia-dashboard/app.js",
"/account/index.html",
]) {
expect(await (await app.request(path)).text(), path).toBe(PAGE_HTML);
}
});
it("answers no other method at a Dashboard page", async () => {
const { app } = buildApp({ discovered: installed() });
for (const path of ["/settings/displays", "/signage"]) {
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
}
}
});
it("refuses at composition a module page that would shadow a platform namespace", () => {
for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) {
expect(
() =>
createDashboardHostRoutes(createDashboardHostAssets(PAGE_HTML, SCRIPT), { modulePages }),
JSON.stringify(modulePages),
).toThrow(DashboardHostConfigurationError);
}
});
});
describe("the platform's reserved top-level names (ADR 0057)", () => {
it("cover every top-level namespace the API registers, so a module page can never shadow one", () => {
const { app } = buildApp({ discovered: [discoveredModule("signage", { ui: true })] });
const topLevel = new Set(
app.routes
.map((route) => route.path.split("/")[1] ?? "")
.filter((segment) => segment !== "" && segment !== "*"),
);
topLevel.delete("signage");
// This composition publishes no display, so the display host's routes are absent here; they
// are checked through their own path constants below.
expect([...topLevel].sort()).toEqual(["account", "api", "assets", "settings"]);
for (const segment of topLevel) {
expect(isReservedModuleId(segment), segment).toBe(true);
}
});
it("cover the display host's pages, service worker, and script, and the Dashboard's script", () => {
for (const path of [
MODULE_DISPLAY_PAGE_PREFIX,
DISPLAY_SERVICE_WORKER_PATH,
DISPLAY_HOST_SCRIPT_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
]) {
expect(isReservedModuleId(path.split("/")[1] ?? ""), path).toBe(true);
}
expect([...RESERVED_MODULE_IDS].sort()).toEqual([
"account",
"api",
"assets",
"display",
"settings",
]);
});
it("include every Dashboard platform page the shell interprets", () => {
for (const page of DASHBOARD_PLATFORM_PAGES) {
expect(RESERVED_MODULE_IDS, page).toContain(page);
}
});
});
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
const { app } = buildApp();
@@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => {
}
});
/**
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
*/
describe("credential lifetime", () => {
const ttlSeconds = 180 * 24 * 60 * 60;
const descriptor = "/api/v1/module-displays/announcements";
function renewal(response: Response): string | undefined {
return setCookieFor(response, "ekklesia_device_session");
}
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
const running = await platform("renewal");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
);
const response = await device.request(descriptor);
expect(response.status).toBe(200);
// The same credential, re-issued with a full lifetime and the same protections.
const header = renewal(response);
expect(header).toBeDefined();
expect(header).toContain(`ekklesia_device_session=${credential};`);
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
expect(header).toMatch(/HttpOnly/u);
expect(header).toMatch(/SameSite=Strict/u);
expect(header).toMatch(/Path=\//u);
expect(header).not.toMatch(/Domain=/iu);
// Using it moved the server's window forward too.
const [row] = await query<{ recent: boolean }>(
running.url,
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
);
expect(row?.recent).toBe(true);
} finally {
await running.close();
}
});
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
const running = await platform("legacy");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
// A production screen paired 179 days ago that checked in this morning: under the old
// rule it had one day left. It must carry straight on.
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
);
expect((await device.request(descriptor)).status).toBe(200);
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
);
expect((await device.request(descriptor)).status).toBe(200);
} finally {
await running.close();
}
});
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
const running = await platform("abandoned");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
);
const response = await device.request(descriptor);
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
expect(response.status).toBe(403);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
it("never renews a revoked screen", async () => {
const running = await platform("revoked-renewal");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const listed = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string }> };
await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
{},
);
const response = await device.request(descriptor);
expect(response.status).toBe(403);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
it("does not hand a device cookie to a person viewing the display", async () => {
const running = await platform("human-renewal");
try {
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
expect(response.status).toBe(200);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
});
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
const running = await platform("mixed");
const device = new DeviceBrowser(running.origin);
+24 -1
View File
@@ -195,6 +195,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
}
}
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
const SHUTDOWN_SETTLE_MS = 3_000;
/**
* The connections left on `databaseName` once a shutdown has had a moment to land.
*
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
*/
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
for (;;) {
const count = await activeConnectionCount(databaseName);
if (count === 0 || Date.now() >= deadline) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
@@ -760,7 +783,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
const eventsAfterShutdown = readRecorder();
+4 -1
View File
@@ -19,7 +19,10 @@
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
"preset": "recommended",
"suspicious": {
"noAlert": "error"
}
}
},
"javascript": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/core",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
+13
View File
@@ -25,6 +25,7 @@ class VerifyArgumentsError extends Error {}
const USAGE =
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
export const MODULE_VERIFIER_SCRATCH_ENV = "EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR";
export interface VerifyModulePackageArguments {
readonly archivePath: string;
@@ -90,6 +91,16 @@ export function parseSha256File(contents: string): string {
return match[1].toLowerCase();
}
/** The CLI's operator-facing bridge to Core's otherwise explicit verifier option. */
export function moduleVerifierScratchDirectory(environment: NodeJS.ProcessEnv): string | undefined {
const configured = environment[MODULE_VERIFIER_SCRATCH_ENV];
if (configured === undefined) return undefined;
if (configured === "" || !path.isAbsolute(configured)) {
throw new VerifyArgumentsError(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
}
return configured;
}
async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
}
@@ -121,6 +132,7 @@ export async function readModulePackagePlatform(
async function main(): Promise<void> {
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
let expectedSha256 = parsed.expectedSha256;
if (parsed.sha256File !== undefined) {
let contents: string;
@@ -137,6 +149,7 @@ async function main(): Promise<void> {
const result = await verifyModulePackage({
archivePath: parsed.archivePath,
platform,
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
});
+18 -8
View File
@@ -44,17 +44,26 @@ export class DeviceStoreError extends Error {
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
/**
* How long a device credential is accepted before the device must pair again.
* How long a device credential may go **unused** before the device must pair again (ADR 0056).
*
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
* decides on every single request whether the device is still revoked, so an administrator's revoke
* takes effect immediately regardless of how long the credential would otherwise have lived. The
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
* The window slides: every request from the device moves `lastSeenAt` forward, and the host
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops
* being an identity at all: 180 days after it was last seen, its credential is dead.
*/
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
/** When a device credential expires: the idle window, measured from the last time the device was
* seen, or from pairing if it never has been. */
export function deviceCredentialExpiresAt(input: {
readonly createdAt: Date;
readonly lastSeenAt?: Date | null | undefined;
}): Date {
const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0);
return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS);
}
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
@@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
}
// The credential's own horizon, measured from when the device was paired. Revocation is
// checked above and is what makes a long horizon acceptable.
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
// Expiry is measured from last use, not from pairing, so a screen in use never ages out.
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
return undefined;
}
return Object.freeze({
+14
View File
@@ -9,6 +9,7 @@ import {
safeParseModuleManifest,
semanticVersionSchema,
} from "@ekklesia/contracts";
import { isReservedModuleId } from "./reserved-ids.js";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
@@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode =
| "MODULE_ENTRYPOINT_INVALID"
| "MODULE_ENTRYPOINT_MISSING"
| "MODULE_ID_DUPLICATE"
| "MODULE_ID_RESERVED"
| "MODULE_MANIFEST_INVALID"
| "MODULE_MANIFEST_MISSING"
| "MODULE_PACKAGE_INVALID"
@@ -614,6 +616,18 @@ async function scanModuleDirectory(
});
}
if (isReservedModuleId(manifest.id)) {
// A module's Dashboard page is `/<module-id>` (ADR 0057), in the same URL space as the
// platform's own top-level namespaces.
issues.push({
code: "MODULE_ID_RESERVED",
message: `Module ID ${manifest.id} is reserved: the platform uses /${manifest.id} for its own pages and routes.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "id",
});
}
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!artifacts) {
return undefined;
+1
View File
@@ -13,6 +13,7 @@ export * from "./module-package-host.js";
export * from "./module-package-verification.js";
export * from "./permissions.js";
export * from "./realtime.js";
export * from "./reserved-ids.js";
export * from "./runtime.js";
export * from "./schema-discovery.js";
export * from "./ui-publication.js";
@@ -1,6 +1,6 @@
import { fork } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -73,7 +73,8 @@ export type ModulePackageVerificationErrorCode =
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED";
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
@@ -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_TRANSITIONAL_UNSUPPORTED:
"The package metadata contains transitional facts this platform does not understand.",
MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE:
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
@@ -157,6 +160,12 @@ export interface VerifyModulePackageOptions {
readonly platform: ModulePackagePlatform;
/** A directory that must not exist yet; the verified module directory is written there. */
readonly extractTo?: string;
/**
* Existing writable directory where backend candidates may execute. Defaults to `os.tmpdir()`.
* A hardened caller whose system temporary directory is `noexec` must provide a dedicated
* executable scratch mount; each verification creates and removes its own child directory.
*/
readonly executionScratchDirectory?: string;
readonly loadTimeoutMs?: number;
}
@@ -414,12 +423,66 @@ function probeEntrypointPath(): string {
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
}
function decodedMountPath(value: string): string {
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
}
/** Linux exposes the effective options of every mount, including a container's tmpfs mounts. A
* verifier cannot infer `noexec` from file permissions: native addons are mapped executable rather
* than invoked as files. On systems without mountinfo, the load remains the final authority. */
async function isNoExecMount(directory: string): Promise<boolean> {
if (process.platform !== "linux") return false;
let mountinfo: string;
let canonicalDirectory: string;
try {
[mountinfo, canonicalDirectory] = await Promise.all([
readFile("/proc/self/mountinfo", "utf8"),
realpath(directory),
]);
} catch {
return false;
}
const mounts = mountinfo
.split("\n")
.map((line) => line.split(" "))
.filter((fields) => fields.length > 6 && fields.includes("-"))
.map((fields) => ({
mountPoint: decodedMountPath(fields[4] ?? ""),
options: (fields[5] ?? "").split(","),
}))
.filter(
({ mountPoint }) =>
canonicalDirectory === mountPoint ||
canonicalDirectory.startsWith(mountPoint === "/" ? "/" : `${mountPoint}/`),
)
.sort((left, right) => right.mountPoint.length - left.mountPoint.length);
return mounts[0]?.options.includes("noexec") ?? false;
}
async function createExecutionScratch(baseDirectory: string): Promise<string> {
if (!path.isAbsolute(baseDirectory)) {
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
try {
if (await isNoExecMount(baseDirectory)) {
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
return await mkdtemp(path.join(baseDirectory, "ekklesia-package-verify-"));
} catch (error) {
if (error instanceof ModulePackageVerificationError) throw error;
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
}
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
* only whether each loaded and exported a definition naming this module. */
async function loadEntrypoints(
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
entrypoints: Readonly<Record<string, string>>,
timeoutMs: number,
executionDirectory: string,
): Promise<void> {
const outcome = await new Promise<"ok" | "failed">((resolve) => {
let settled = false;
@@ -445,7 +508,7 @@ async function loadEntrypoints(
}),
],
{
cwd: tmpdir(),
cwd: executionDirectory,
env: {},
execArgv: [],
serialization: "json",
@@ -514,7 +577,7 @@ export async function verifyModulePackage(
const manifest = checkIdentity(archive, metadata, files);
checkCompatibility(metadata, manifest, files, options.platform);
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-"));
const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
try {
// The candidate alone, in a scratch inventory of its own: never module storage.
const modulesDirectory = path.join(scratch, "candidates");
@@ -550,7 +613,7 @@ export async function verifyModulePackage(
// Executed only now, and first in isolation: a schema is imported in this process below only
// after the probe proved it loads on its own.
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
try {
await discoverModuleSchemas(discoveryOptions);
} catch (error) {
+33
View File
@@ -0,0 +1,33 @@
/**
* Top-level names the platform owns, which therefore can never be a module id (ADR 0057).
*
* Every module with a Dashboard UI is addressed at `/<module-id>`, beside the platform's own
* top-level namespaces in one URL space. A module called `settings` would collide with the
* Dashboard's Settings pages, and one called `api` with the API itself. This is the complete set the
* platform serves today, not a guess at what it might want later:
*
* - `api` — the API: identity, principal, health, Core resources, module routes, module UI artifacts,
* display descriptors, and realtime (`/api/…`);
* - `assets` — the Dashboard's and display host's scripts (`/assets/ekklesia-dashboard/…`,
* `/assets/ekklesia-display/…`);
* - `display` — the display host's pages and service worker (`/display/…`);
* - `settings` and `account` — the Dashboard's own Settings and account areas.
*
* `apps/api` holds the list to the routes it actually registers, and to the Dashboard namespaces the
* shell interprets, so a new top-level platform route cannot be added without reserving it here.
* Reserving a name is a breaking change for any installation with a module of that id, so the set
* grows only deliberately.
*/
export const RESERVED_MODULE_IDS = Object.freeze([
"account",
"api",
"assets",
"display",
"settings",
] as const);
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);
export function isReservedModuleId(moduleId: string): boolean {
return reserved.has(moduleId);
}
@@ -169,13 +169,41 @@ describe("the browser shell reaches none of it", () => {
});
it("never puts a reset token in browser storage", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
// A reset token is a bearer credential for one account; the shell holds it in React state for
// the life of one form and nowhere else. The shell's one use of browser storage is the desktop
// sidebar's expanded/collapsed mode (ADR 0058), so that use is pinned exactly: `localStorage` is
// named only by the entry point, and only to hand it to the sidebar preference, whose one write
// is its own fixed key and a validated mode.
const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src"));
const writes: string[] = [];
for (const file of files) {
const code = withoutComments(file.text);
// A reset token is a bearer credential for one account; the shell holds it in React state for
// the life of one form and nowhere else.
expect(code, file.path).not.toContain("localStorage");
expect(code, file.path).not.toContain("sessionStorage");
expect(code, file.path).not.toContain("document.cookie");
writes.push(
...[...code.matchAll(/\.setItem\(([^)]*)\)/gu)].map(
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
),
);
if (file.path.endsWith(path.join("browser", "main.tsx"))) {
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
expect(code).toMatch(
/createLocalSidebarPreference\(\s*\(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
);
} else {
expect(code, file.path).not.toContain("localStorage");
}
}
expect(writes).toEqual([
`${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`,
]);
// 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);
}
});
});
+48
View File
@@ -4,8 +4,10 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
discoverModules,
isReservedModuleId,
ModuleDiscoveryError,
type ModuleDiscoveryIssueCode,
RESERVED_MODULE_IDS,
} from "../src/modules/index.js";
interface ModuleFixtureOptions {
@@ -271,6 +273,52 @@ describe("module discovery", () => {
);
});
it("rejects a module whose id is one of the platform's reserved top-level names (ADR 0057)", async () => {
// Every module with a Dashboard UI is addressed at `/<module-id>`, so an id equal to a
// platform-owned top-level namespace would collide with the API, the display host, a host
// script, or a Dashboard page.
expect(RESERVED_MODULE_IDS).toEqual(["account", "api", "assets", "display", "settings"]);
expect(Object.isFrozen(RESERVED_MODULE_IDS)).toBe(true);
for (const id of RESERVED_MODULE_IDS) {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, id);
const error = await discoveryError(modulesDirectory);
expect(error.issues, id).toEqual([
expect.objectContaining({
code: "MODULE_ID_RESERVED",
moduleDirectory: id,
moduleId: id,
path: "id",
}),
]);
}
});
it("accepts ids that merely resemble a reserved name", async () => {
const modulesDirectory = await createModulesDirectory();
for (const id of ["settings-sync", "my-account", "displays", "api-docs"]) {
await createModule(modulesDirectory, id);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(modules.map((module) => module.manifest.id).sort()).toEqual([
"api-docs",
"displays",
"my-account",
"settings-sync",
]);
expect(isReservedModuleId("settings")).toBe(true);
expect(isReservedModuleId("settings-sync")).toBe(false);
});
it("enforces platform and Module SDK compatibility", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "future", {
+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 path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -48,6 +48,12 @@ async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
return { archivePath, sha256: fixture.sha256 };
}
async function executionScratch(): Promise<string> {
const directory = path.join(await scratch(), "execution");
await mkdir(directory);
return directory;
}
async function rejection(
options: PackageFixtureOptions,
code: ModulePackageVerificationErrorCode,
@@ -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 () => {
const { archivePath } = await archiveFile();
const target = path.join(await scratch(), "example");
+12
View File
@@ -1,5 +1,6 @@
import {
EMPTY_MODULE_STORAGE,
isModuleStorageError,
isValidModuleStorageKey,
MODULE_STORAGE_KEY_MAX_BYTES,
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
}
});
it("recognizes a storage error from an installed module's separate package copy", () => {
const external = Object.assign(new Error("The storage service could not be reached."), {
code: "STORAGE_PROVIDER_UNAVAILABLE",
name: "ModuleStorageError",
});
expect(external).not.toBeInstanceOf(ModuleStorageError);
expect(isModuleStorageError(external)).toBe(true);
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
});
it("gives a module no way to reach another module's storage by knowing its name", async () => {
const shared = fakeProvider();
const announcements = createModuleStorage({
@@ -3,6 +3,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
MODULE_VERIFIER_SCRATCH_ENV,
moduleVerifierScratchDirectory,
parseSha256File,
parseVerifyModulePackageArguments,
readModulePackagePlatform,
@@ -57,6 +59,21 @@ describe("verify-module-package digest files", () => {
});
});
describe("verify-module-package execution scratch", () => {
it("accepts an absolute operator-configured directory and otherwise leaves the library default", () => {
expect(moduleVerifierScratchDirectory({})).toBeUndefined();
expect(
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: "/run/ekklesia-verifier" }),
).toBe("/run/ekklesia-verifier");
});
it.each(["", "relative/path"])("refuses the invalid configured path %j", (configured) => {
expect(() =>
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: configured }),
).toThrow(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
});
});
describe("verify-module-package platform facts", () => {
it("reads this platform's versions and its transitional database runtime from the repository", async () => {
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
+14 -1
View File
@@ -42,9 +42,16 @@
{$EKKLESIA_HOSTNAME} {
# Everything the API serves: the Dashboard at /, the API, display pages and their service
# worker, and the Dashboard and display host scripts. Any other path is refused here.
# worker, and the Dashboard and display host scripts.
@ekklesia path / /api/* /display/* /assets/*
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
# Any other path is refused here.
@dashboardPage path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
# Module realtime is Server-Sent Events: flush every event immediately.
@realtime path /api/v1/module-realtime/*
@@ -63,6 +70,12 @@
}
}
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle {
respond 404
}
+16
View File
@@ -51,12 +51,28 @@
{$EKKLESIA_DASHBOARD_HOSTNAME} {
@dashboard path / /assets/ekklesia-dashboard/*
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
# The API's own namespaces stay refused on this hostname.
@dashboardPage {
path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
not path /api /api/* /display /display/* /assets /assets/*
}
handle @dashboard {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle {
respond 404
}
+30 -6
View File
@@ -17,6 +17,7 @@ specific to one organization.
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
| [`display-kiosk.md`](display-kiosk.md) | Setting up an unattended display screen: launch, pairing, and recovery. |
## The reference deployment
@@ -170,9 +171,20 @@ identity, target, and Node.js range against this platform, runs the platform's m
validation, and loads the backend once in a short-lived process with no environment. Only then does
it write the verified module directory, to a new directory outside module storage:
The backend probe executes the package's server-side code; the verifier container is its isolation
boundary. Keep the general-purpose `/tmp` mount `noexec`, and give only this one-shot verifier a
separate executable scratch tmpfs. The mount below is ephemeral, bounded at 1100 MiB (just above the
package format's 1 GiB expanded limit), `nodev`, `nosuid`, and writable only by the image's
unprivileged `node` user. `EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR` tells the CLI where that mount is;
the verifier creates a unique child for each run and removes it on success or failure.
```sh
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
docker run --rm --network none --read-only --tmpfs /tmp \
docker run --rm --network none --read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
--cap-drop ALL --security-opt no-new-privileges \
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
@@ -229,6 +241,14 @@ Module storage is installation state: back it up with the database and the bucke
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
**Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with
the new `Caddyfile.example` (or `Caddyfile.split.example`). Each gains a `@dashboardPage` matcher
and `handle` block that forward every top-level path shaped like a module id — `/settings`,
`/account`, and each installed module's page such as `/signage` — to the API, which answers with
the Dashboard page where one exists and its sanitized `404` elsewhere. Without it the Dashboard still
opens at `/`, but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the
proxy after editing it. Installing or removing a module needs no proxy change.
### Rolling back
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
@@ -251,7 +271,7 @@ The same image serves both; only configuration differs.
| Hostname | Serves | Refuses (`404`) |
| --- | --- | --- |
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, `/assets/ekklesia-dashboard/*`, and every module-shaped top-level path (the Dashboard's pages, such as `/settings` and `/signage`; the API answers the rest with its `404`) | everything else, including `/api/*`, `/display/*`, and `/assets/*` |
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
@@ -327,7 +347,11 @@ networks:
Then make your proxy do what `Caddyfile.example` does:
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`,
and every path whose first segment is shaped like a module id, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$` (ADR 0057): that is
`/settings`, `/account`, and each installed module's page such as `/signage`, and the API answers
anything that is not a real page with its sanitized `404`. Without it, refreshing any Dashboard page
other than `/` is your proxy's `404`. Do not list module ids: they change as modules are installed;
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
@@ -393,7 +417,7 @@ deployment:
| # | Item | Where it is met |
| --- | --- | --- |
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*`, and every module-shaped top-level path (the Dashboard's pages) on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
@@ -500,10 +524,10 @@ finds its files:
```sh
docker build \
--file deploy/Dockerfile \
--build-arg VERSION=0.2.2 \
--build-arg VERSION=0.2.5 \
--build-arg REVISION="$(git rev-parse HEAD)" \
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
--tag ekklesia:0.2.2 \
--tag ekklesia:0.2.5 \
.
```
+4
View File
@@ -132,6 +132,10 @@ services:
command:
- mini
- -dir=/data
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
# volume size leaves modest hosts with room for only a few collections; after that, writes
# stall instead of failing. This changes allocation granularity, not object or total capacity.
- -master.volumeSizeLimitMB=32
- -s3.port.https=8443
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
- -s3.key.file=/run/ekklesia/tls/object-storage.key
+130
View File
@@ -0,0 +1,130 @@
# Setting up a display kiosk
A display is a screen — a television with a small computer, or a mini PC on a wall — that opens one
module's display page full screen and keeps it open unattended. This guide sets one up so it starts
on boot, stays paired, recovers from network and server outages on its own, and never opens a second
copy of itself.
Pairing and display identity belong to the platform (ADR 0036). Today an administrator approves a
screen in the Dashboard sidebar's **Display devices** panel; a later release moves this to
**Settings → Displays**. Nothing below changes when that happens.
## What the screen needs
- Chromium or Google Chrome, current stable. The display page is tested against Chromium.
- A **dedicated, persistent browser profile directory**. The device credential is an HttpOnly cookie
in that profile; it is what keeps the screen paired across reboots. Never use a guest, incognito, or
"clear data on exit" profile, and never share the profile with a person's browsing.
- Network access to the installation's **API hostname** (the display page, its realtime stream, and
its module requests) and to the storage hostname that serves images.
- A clock that is roughly right. Playback follows the server's clock, but TLS does not tolerate a
wildly wrong one.
## The address
Every display-capable module has one display page on the API hostname:
```text
https://<api-hostname>/display/<module-id>
```
For Signage that is `https://<api-hostname>/display/signage`. The Signage **Displays** tab shows the
exact address under **Copy setup address**. Open it on the screen itself; opening it on an
administrator's own computer only previews the display (and that browser appears in the roster as a
*Signed-in browser*, which can be removed once it is offline).
## Launching the browser
Run exactly **one** launcher, and launch the page as an app window with a fixed profile:
```bash
chromium \
--user-data-dir=/var/lib/display-kiosk/profile \
--app=https://<api-hostname>/display/signage \
--kiosk \
--no-first-run \
--noerrdialogs \
--disable-session-crashed-bubble \
--hide-crash-restore-bubble \
--disable-features=Translate \
--autoplay-policy=no-user-gesture-required \
--check-for-update-interval=31536000
```
- `--app` opens the page with no tab strip and no address bar. `--kiosk` makes it full screen.
- `--user-data-dir` is the persistent profile that holds the pairing. Keep it on persistent storage
and back it up with nothing: if it is lost, the screen simply shows a new pairing code.
- Do **not** add `--restore-last-session`, and do not also start the browser from a desktop autostart
entry. Chromium allows one process per profile; a second launch hands its URL to the running
process, which opens a **second window or tab** of the display. That is the usual cause of
"duplicate" displays. One launcher, one profile, one window.
### Start on boot and restart on exit (systemd)
Run the browser as an unprivileged user in that user's graphical session, from a single user unit:
```ini
# ~/.config/systemd/user/display-kiosk.service
[Unit]
Description=Display kiosk
After=graphical-session.target network-online.target
Wants=network-online.target
PartOf=graphical-session.target
[Service]
ExecStart=/usr/bin/chromium --user-data-dir=%h/.local/share/display-kiosk --app=https://<api-hostname>/display/signage --kiosk --no-first-run --noerrdialogs --disable-session-crashed-bubble --hide-crash-restore-bubble --disable-features=Translate --autoplay-policy=no-user-gesture-required --check-for-update-interval=31536000
Restart=always
RestartSec=5
[Install]
WantedBy=graphical-session.target
```
```bash
systemctl --user daemon-reload
systemctl --user enable --now display-kiosk.service
loginctl enable-linger <kiosk-user> # with the desktop set to log this user in automatically
```
`systemctl --user stop display-kiosk` closes the browser cleanly, which is what keeps its profile —
and the pairing cookie — intact. Avoid cutting power to a running kiosk when you can: a clean shutdown
is always safer for a browser profile.
## Pairing
1. Start the kiosk. With no credential it shows a **six-digit pairing code**.
2. In the Dashboard, enter the code under **Display devices**, give the screen a name, and approve.
3. Within a few seconds the screen collects its credential and starts showing content.
4. In Signage → **Displays**, name the display and choose its group and audience.
A paired screen stays paired across browser restarts and reboots, and for as long as it is in use:
its credential is renewed each time it checks in, and only expires after **180 days without being
used** (ADR 0056). A screen that is switched off and stored for longer than that, or that is revoked,
returns to its pairing code and is paired again the same way.
## What happens when things go wrong
| Event | What the screen does |
| --- | --- |
| Network drops for seconds | Nothing visible. The display keeps running; the realtime stream reconnects. |
| Network or API down for longer | Keeps playing what it has cached, with a small corner note; recovers by itself when the server is back. No remount, no reload. |
| API restarted or upgraded | Reconnects by itself. A new module version is picked up within five minutes and swapped in. |
| Browser crashes or is restarted | systemd restarts it; the same profile is still paired. |
| Machine reboots | Starts on boot and resumes. If the server is not up yet, the screen retries on its own (every few seconds, backing off to once a minute) until it is. |
| Display revoked in the Dashboard | Stops showing content within seconds and returns to its pairing code. |
| Display moved to another group or audience | Switches content within about one heartbeat (≈12 s), no reload. |
**Offline cold start is not supported yet.** A screen that is already showing content survives an
outage. A screen that *boots* while the server is completely unreachable can load its cached page but
cannot mount the module until the server answers, because the authorization it needs is never cached
(ADR 0043, Signage ADR 0006). It shows a short message and retries on its own until the server is
reachable — nobody needs to touch it.
## Checklist for a new kiosk
- [ ] One systemd unit, no desktop autostart entry, no second launcher.
- [ ] `--user-data-dir` on persistent storage, not shared, not cleared on exit.
- [ ] Automatic login of the kiosk user, and screen blanking/power saving disabled.
- [ ] Paired and named; assigned in Signage.
- [ ] Unplug the network for a minute: content keeps playing, then the note clears on reconnect.
- [ ] Reboot: the screen comes back paired without anyone touching it.
@@ -267,6 +267,8 @@ to follow — and the API never builds frontend source at run time.
and its only allowed build script, and a second toolchain buys HMR that a proof does not need.
- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus
module routing is a design this pass should not settle by accident.
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
> now has host-owned URL routing; module routing remains a separate, deferred decision.
- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs
to the host, and adopting it here for a two-item selector would prejudge that design.
- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
@@ -255,6 +255,10 @@ that is unremarkable, and it is measured work rather than hidden.
## Deferred
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** Client-side
> routing and deep links to a selected module, deferred below, have landed; the rest of this list is
> unchanged.
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
@@ -165,6 +165,10 @@ redirect target.
A future client router may define an internal return-location contract, but it must be a separately
validated, same-origin route decision. This pass does not create one speculatively.
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
> now has a host-owned router. It adds no return-location contract: sign-in still happens in place at
> the current URL, which is now a real page address.
### Cookie and origin policy does not broaden for the Dashboard
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
@@ -2,6 +2,7 @@
- Status: Accepted
- Date: 2026-08-24
- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md)
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
[0032](0032-native-module-realtime-transport.md),
[0033](0033-display-kiosk-host-contract.md),
@@ -0,0 +1,76 @@
# 0056: A device credential lasts while the device is in use
- Status: Accepted
- Date: 2026-10-12
- Supersedes: the "Session lifetime" section of [0036](0036-managed-device-identity-and-pairing.md).
Everything else in ADR 0036 — the device as its own principal, pairing, revocation on every
request, re-pairing as a new identity — is unchanged.
## Context
ADR 0036 gave a device credential a fixed 180 days from pairing. The intent was that a screen on a
ceiling should not need re-pairing on a short cycle, and that an abandoned screen should eventually
stop being an identity.
A fixed horizon does the second and fails the first. Every screen in the building expires on the
anniversary of its pairing whether or not it was in use the day before, and drops to a pairing code
in front of the congregation until someone with Dashboard access walks to it. For screens paired
together, that is every screen at once. Nothing about a working, unrevoked screen is less trustworthy
on day 181 than on day 179; only the calendar changed.
## Decision
**The 180 days are an idle window, not a lifetime.** A device credential is accepted while the
device was last seen less than 180 days ago (or, if it has never been seen, was paired less than 180
days ago). `lastSeenAt` already moves forward on authenticated device requests (throttled to one
write per 15 minutes), so the server-side window slides with use and needs no new column or
migration.
**The browser's copy is renewed by the host, not rotated.** The display descriptor —
`GET /api/v1/module-displays/<module>`, which every display requests when it starts, when it
reconnects, and periodically while it runs — re-issues the device cookie that authenticated the
request with a fresh 180-day `Max-Age` and the same attributes as at pairing (`HttpOnly`,
`SameSite=Strict`, host-only, `Path=/`, `Secure` on https). The value is unchanged.
Renewal happens only for a request that already resolved to a live, unrevoked device. A revoked,
expired, or unknown credential is anonymous, as before, and is never renewed; a person viewing a
display never receives a device cookie.
**Revocation remains the boundary.** It is still checked on every request, and an administrator's
revoke still takes effect immediately.
## Why not rotate
Rotation (issue a new value, invalidate the old) would bound how long any one stolen value works and
could reveal a cloned credential. For an unattended screen it trades that for a new way to strand
the display: a renewal response lost to a network drop, a power cut, or a browser that never
persisted the new cookie leaves the screen holding a dead credential and showing a pairing code,
which is the failure this decision exists to remove. A refresh-token scheme with overlap windows
could avoid that, at the cost of real complexity in the one place mistakes are invisible until a
wall goes dark. That is not justified while revocation is immediate and a device principal can only
read the displays it was paired for.
## Consequences
- A screen that is used at least once every 180 days stays paired until an administrator revokes
it. An abandoned screen still stops being an identity 180 days after it was last seen.
- Existing credentials need no migration. A screen paired under the fixed rule is renewed the next
time it requests its descriptor, provided its browser still holds the cookie.
- A stolen device credential stays usable for as long as anyone keeps using it, until it is revoked.
Under ADR 0036 it was usable for at most 180 days from pairing. The exposure is bounded by what a
device may do — the display permissions of the module it was paired for, never a person's access
— and by revocation. Operators can see each device's last-seen time and revoke anything
unexpected.
- A credential is not bound to a browser or network. Like a human session cookie, whoever presents
it is the device.
## Alternatives
- **Keep the fixed 180 days.** Rejected: every in-use screen fails on a schedule, and screens paired
together fail together.
- **Never expire.** Rejected: an abandoned or forgotten screen would remain an identity forever, and
its credential would never stop working without someone remembering to revoke it.
- **Rotate on renewal.** Rejected for now; see above.
- **Renew on every authenticated response.** Rejected: principal resolution happens deep in shared
middleware that never writes responses. One endpoint every display already requests periodically
is enough, and keeps the behaviour in one visible place.
@@ -0,0 +1,237 @@
# 0057: Dashboard URLs are real, host-owned routes
- Status: Accepted
- Date: 2026-10-14
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
[0027](0027-dashboard-navigation-and-flat-modules.md),
[0034](0034-host-owned-browser-authentication.md),
[0044](0044-self-hosted-reference-deployment.md),
[0045](0045-split-dashboard-and-api-origins.md)
## Context
The Dashboard had exactly one URL. `apps/api` served the page at `/` and nowhere else, both
reference proxies forwarded only `/` to it, and the shell kept "which module is open" in React state.
Opening a module never changed the address bar, so a refresh always returned to the first module,
nothing could be bookmarked or shared, and the browser's Back button left the Dashboard entirely.
ADR 0026 rejected a client router *for that pass* — "a selected-module state is sufficient" — and
ADR 0027 deferred client routing and deep links.
The product contract now requires them. DESIGN.md §2.7 says the URL represents the current place in
the product: a refresh returns to the same page, deep links work, Back and Forward behave
predictably. §5 makes an **Overview** the Dashboard's landing page, and §12.6 says a disabled module's
routes stop behaving as active routes. Everything else Stage 1 builds — the sidebar, Settings, the
account area, platform Displays, a module's own pages in the sidebar, and returning to the workflow a
Settings deep link came from — needs stable addresses to hang from, so this is the first slice.
A route that works in the shell but not on refresh is worse than no route, because it looks like it
works. Refresh is a server and proxy concern before it is a browser one: the API had to serve the
page at the new paths, and the proxies had to forward them.
## Decision
### One top-level URL space, shared by platform pages and module pages
```text
/ the Overview
/settings[/…] Settings
/account[/…] the account area
/<module-id> a module's page: /signage, /calendar, /people
/<module-id>/<page>[/…] reserved for that module's own pages: /signage/timelines
```
A module is a first-class top-level destination, addressed by its id, exactly as Settings and the
Account area are. "Module" is an implementation concept — how the platform packages a product — and
it does not appear in the addresses people see, bookmark, and share. A module id is lowercase
kebab-case and Core-validated, which is what makes it usable as a path segment with no further
encoding.
### Platform-owned top-level names are reserved, and Core enforces it
Sharing one namespace means a module must never be able to take a name the platform uses.
`RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`) is the complete set the platform serves
at the top level **today**, taken from what is actually registered rather than from guesses:
| Name | Owned by |
| --- | --- |
| `api` | the API: identity, principal, health, Core resources, module routes, module UI artifacts, display descriptors, realtime |
| `assets` | the Dashboard's and display host's scripts |
| `display` | the display host's pages and service worker |
| `settings` | the Dashboard's Settings pages |
| `account` | the Dashboard's account area |
Discovery refuses a module that declares one (`MODULE_ID_RESERVED`), so the check covers API
startup, `pnpm modules:validate`, and Module Package verification — before a module is installed,
not only when it is served. Exact names only: `settings-sync` or `displays` are ordinary ids.
The set is held to reality by tests in `apps/api` rather than by review: every top-level segment the
composed API registers is a reserved name or a module page, the display host's and Dashboard
script's path constants all start with a reserved name, and every Dashboard page the shell
interprets is reserved. Adding a new top-level platform route therefore fails a test until its name
is reserved. Reserving a name later is a **breaking change** for any installation with a module of
that id, so the set grows only deliberately, and only for a real route.
The rule lives in Core's discovery rather than in the module manifest grammar
(`@ekklesia/contracts`), because it is the Ekklesia host's URL space, not a property of a module: a
standalone host (ADR 0021) has no Settings page to protect. A module built against the current
public packages is unaffected unless it uses one of these five names.
### The server serves one page, unchanged, at every real Dashboard address — and nowhere else
`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at `/settings` and
`/account` and everything below each (`DASHBOARD_PLATFORM_PAGES`, exported by the shell as
`@ekklesia/shell/dashboard-routes`, so the server and the shell's router read one list), and at
`/<module-id>` and everything below it for **each module this process discovered that declares a
Dashboard UI**. Every answer is the same frozen bytes, the same strong `ETag`, and the same security
headers as `/`: the path selects a page inside the shell, never a file on the server. Nothing from
the request is composed into a lookup, no directory is exposed, and the script and source map remain
reachable only at their own literal paths.
The module set is the boot snapshot's discovered inventory, fixed at startup like every other
module-derived route, and it deliberately **includes disabled and blocked modules**. A bookmark to a
module an administrator turned off still opens the Dashboard, which says the module is unavailable;
that is more useful than an address that suddenly looks like it never existed. A module that is not
installed, or that has no Dashboard UI (a display-only module, ADR 0033), has no page.
Everything else stays the sanitized `404`: an arbitrary root path (`/definitely-not-a-real-module`),
a look-alike (`/settings-old`, `/signages`), another method, `/index.html`, and so on. The routes
also refuse to compose if handed a module page that is not a valid module id, is a reserved name, or
appears twice — discovery already guarantees all three, and the routes do not trust it.
Serving `/<module-id>` before sign-in means an anonymous request can learn whether a module with a
Dashboard UI is installed, by its `200` or `404`. That is accepted: it is the same disclosure the
display host already makes at `/display/<module-id>` (ADR 0033), a module id names a product rather
than any data, and the alternative — a page at every module-shaped root path — is exactly the
"arbitrary path becomes a Dashboard page" this decision rejects. The authenticated module inventory
and every module route keep their authorization unchanged.
### The proxies forward module-shaped names and let the API decide
A proxy cannot know which modules an installation has, and a proxy configuration listing them would
have to be edited on every install. Both reference Caddyfiles therefore forward every top-level path
whose first segment has a module id's shape, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$`, and the API
answers it with the page or its sanitized `404`. Anything not shaped like one — `/favicon.ico`,
`/.well-known/…`, an uppercase name — is still refused by the proxy without reaching the API. In the
split topology (ADR 0045) the Dashboard hostname forwards module-shaped names but still refuses
`/api`, `/display`, and `/assets`, and the API hostname still serves no Dashboard page. The
development server falls back to the page for any path with no file.
### The shell's router is host-owned, small, and deliberately not a library
The address bar is the source of truth for which page is open. The shell reads it through a
four-method `HostHistory` (`pathname`, `push`, `replace`, `subscribe`); the browser implementation is
built in the entry point from `window` and handed down, so the entry point remains the only file in
the package that touches a browser global, and every routing behaviour is tested against an in-memory
history whose entries a test can inspect. No state object is ever stored in a history entry — the path
alone is meaningful — so nothing put into history can be read back as authority.
`react-router-dom` is not adopted. Modules are refused it because routing belongs to the host
(ADR 0022, `pnpm boundaries`); the shell's own pages are a handful of host screens plus one mounted
module; and adopting a library now would prejudge how a module's own pages are addressed, which is
a separate decision (see **Deferred**).
### What each path shows
- `/` is the **Overview**. The shell no longer opens the first module for the person: the Overview is
the landing page. Until modules can contribute cards to it, it says plainly that it has nothing to
show yet.
- `/<module-id>` mounts the module whose navigation entry has that id, through the unchanged generic
loader and the unchanged keyed frame, so a stale load still cannot reach a newer page. The server's
navigation is the only authority on what is mountable. An id it does not list — a disabled or
blocked module whose address the server still serves — shows *This page isn't available* and loads
nothing; the page never echoes the address back. This is what makes a disabled module's routes stop
behaving as active routes, with no new server state.
- `/settings…` and `/account…` are the platform's own pages and are never read as module pages. They
have no content yet, so they show *Page not found* for now.
- Everything below `/<module-id>/` belongs to that module's own pages. It is served by the API today
and shows *Page not found* in the shell until the module page contract exists. The shell never
guesses, and never shows a module's first page for an address the module never defined.
- One trailing slash is tolerated and corrected to the canonical spelling in place (`replace`, no new
entry), so what is bookmarked next is the one canonical address.
### Navigation is real links
Every navigation item is an `<a href>` with its canonical address and `aria-current="page"` when it
is open, marked by weight as well as colour. An ordinary click opens the page in place and adds one
history entry; a click with a modifier key or the middle button is left to the browser, so a page can
be opened in a new tab. Clicking the open page's own link adds no entry. The browser's Back and Forward
walk exactly the pages that were open.
### Page changes behave like page loads for assistive technology
After an in-place page change the shell moves focus to the page's `<main>` (named after the page) as
a full load would have; the first page shown after a refresh or sign-in is left alone. The entry point
names the browser tab after the open page ("Overview", the module's display name, or the error page's
title, and "Sign in" while signed out) — never "Ekklesia", in keeping with ADR 0046. The static page's
own `<title>` is unchanged here; it belongs to the branding work.
### Sign-in is unchanged, and still has no return URL
ADR 0034 holds exactly. The sign-in form still replaces the application in place at the current URL,
so a deep link opened while signed out lands on its own page once the person signs in — with no
`return`, `next`, or callback value, no redirect, and no script-driven navigation off the
Dashboard's own paths. The shell only ever moves between its own canonical paths. Emailed
verification and reset links still land on `/` with a query (ADR 0038).
## Alternatives
- **Module pages under a prefix (`/modules/<id>`).** Rejected: it exposes an implementation concept
in every address people see, and makes a module a second-class destination beside Settings. The
collision it avoided is handled instead by reserving the platform's names in Core.
- **Hash routing (`/#/signage`).** Rejected: every address is one page to the server, the proxy, and
any analytics; fragments never reach the server, so no server-side decision about a path is
possible; and DESIGN.md asks for real URLs.
- **Serving the page at any module-shaped root path.** Rejected: `/definitely-not-a-real-module`
would become a Dashboard page, and the server would answer `200` for every mistyped address. It
would remove the installed-module disclosure described above, at the cost of the server no longer
meaning anything by a `200`.
- **Serving only enabled, running modules' pages.** Rejected: a bookmark to a module an
administrator disabled would become a bare `404`, indistinguishable from a typo, instead of the
Dashboard explaining that the module is unavailable.
- **Listing modules in the proxy configuration.** Rejected: every install would need a proxy edit.
- **Reserving more names now** (`login`, `help`, `overview`, …) "just in case". Rejected: a reserved
name refuses a real module, so a name is reserved when the platform gets a route there and not
before.
- **Putting the reserved names in the manifest grammar.** Rejected for now: it is the Ekklesia host's
URL space, not a module property, and would need a public-contract release for no extra safety,
since discovery already refuses the module before it can be installed.
- **`react-router-dom` or another routing library.** Rejected for now; see above.
- **Keeping "open the first module" at `/`.** Rejected: DESIGN.md makes the Overview the landing page,
and an address that silently means "whatever happens to be first" is not a stable address.
## Consequences
- A refresh, a bookmark, and a shared link land on the page that was open; Back and Forward walk the
Dashboard's own pages. Proven at three levels: the shell against an in-memory history; `apps/api`
serving the page at platform pages and installed modules' pages and the sanitized `404` everywhere
else; and the deploy smoke test, through the real proxy, checking platform deep links, unknown
names, a module's address staying served while the module is disabled and disappearing and
returning as it is removed and reinstalled,
and reloading a module's address in a real browser and requiring the same module to mount again.
- **The Dashboard opens on the Overview, not on the first module.** A person who used to land in
Signage now chooses it once from the navigation, and can bookmark `/signage`.
- **A module may not be called `account`, `api`, `assets`, `display`, or `settings`.** No known module
is; one that was would now fail discovery with `MODULE_ID_RESERVED` and must be renamed.
- **Operators with their own proxy must forward module-shaped top-level paths** (the matcher above),
or a refresh on any page other than `/` is the proxy's `404`. The deployment guide says so.
- `@ekklesia/shell` gains a `./dashboard-routes` subpath, and `apps/api` reads it. The surface remains
private; this is the same relationship `./dashboard-assets` already has.
- The navigation landmark is labelled "Dashboard" rather than "Modules", because it now contains the
Overview as well as modules.
## Deferred
Each of these needs its own decision. This ADR reserves the addresses for them and decides none of
them:
- **A module's own pages** — how a module declares the pages that appear under it in the sidebar
(`/signage/playback`, `/signage/timelines`, `/signage/displays`), how it learns and changes its
current sub-path through the module UI SDK, and what an address below `/<module-id>/` means. The
server already serves the page there and the proxies already forward it, so that contract is a
manifest, SDK, and shell change with no further routing or deployment change. It is its own ADR.
- **Return to origin** — how a module or a Settings page links into another part of the Dashboard and
comes back to the exact workflow that sent the person there (DESIGN.md §2.7, §4.8). It must remain
a validated, same-origin route decision, as ADR 0034 requires, and is designed with the module page
contract rather than here.
- The Settings and Account pages themselves, the sidebar redesign, the mobile drawer, Overview cards,
breadcrumbs, and the installation's name in the browser tab.
@@ -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.
+5 -1
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
- [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
- [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
- [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
@@ -64,3 +64,7 @@ ADRs capture durable choices that affect more than one package or phase.
- [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
- [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
+14 -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.
**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
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
@@ -588,7 +592,11 @@ 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
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
`: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)). `pnpm boundaries` opens
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
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
@@ -701,7 +709,10 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
- **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,
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,
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.
+13 -1
View File
@@ -233,7 +233,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?**,
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.
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
@@ -259,6 +261,16 @@ redirect target and performs no post-login navigation, so external and protocol-
have no redirect sink in this pass. See
[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 shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
+6 -6
View File
@@ -207,10 +207,10 @@ Two builds of the same commit produce byte-identical tarballs, and CI proves it
## Publishing the train
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
https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
https://git.zayin.net/api/packages/Zayin/npm/
```
| Who | May |
@@ -272,7 +272,7 @@ Evidence that one train was published, not a second package catalog, and never a
{
"publicationRecord": 1,
"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",
"completedAt": "<ISO time>",
"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
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
`write:package` also allows deleting packages; the tool never does, and nothing else uses the
token.
@@ -319,7 +319,7 @@ guarantee:
- **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
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
check that.
@@ -359,7 +359,7 @@ claim to be source that 0.1.2 never contained.
Before it, an administrator of the Gitea instance:
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;
3. creates a token for the account with only `write:package`, and stores it as the repository
Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`;
+7 -1
View File
@@ -484,7 +484,13 @@ before it is installed: its digest, its archive safety (no links, special files,
escaping paths), every file's digest, and its identity and compatibility. The digest proves
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
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:
+4 -1
View File
@@ -403,7 +403,10 @@ 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
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,
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 only use of browser storage is the sidebar's expanded/collapsed mode — `localStorage` named
only by the entry point, one write of one fixed key, and no storage API in any file that holds the
reset token ([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)).
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:
+50 -3
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:
```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:
@@ -318,7 +318,10 @@ 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
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.
[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
installed package normally has no source, so the platform cannot check it the same way. Your
@@ -532,7 +535,12 @@ without executing module code:
}
```
Module, capability, and permission identifiers are lowercase and namespaced. A provided capability
Module, capability, and permission identifiers are lowercase and namespaced. A module with a
Dashboard UI is addressed at `/<module-id>` — `/calendar` for this one
([ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)) — in the same
top-level URL space as the platform's own pages, so a few ids are **reserved** and Core refuses a
module that declares one (`MODULE_ID_RESERVED`, reported by discovery, `pnpm modules:validate`, and
package verification): `account`, `api`, `assets`, `display`, and `settings`. A provided capability
has its own exact semantic version because its public contract can evolve independently from the
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.
@@ -568,6 +576,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*
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
`name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees
@@ -880,6 +899,34 @@ 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
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
Author plain `.css` beside your UI source, import it, and declare it
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ekklesia",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/contracts",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"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,
* not characters, because that is what the wire format bounds. */
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/design-tokens",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-database",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-pack",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
@@ -0,0 +1,60 @@
#!/usr/bin/env node
// Builds the repository's real sharp proof as a native Module Package for the hardened verifier
// container test. Run inside deploy/Dockerfile's build stage, where the workspace is already built
// and has exactly the locked dependencies the runtime image was built against.
import { cp, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { packModule } from "@ekklesia/module-pack";
const repositoryRoot = path.resolve(import.meta.dirname, "..", "..", "..", "..");
const outDirectory = process.argv[2];
if (outDirectory === undefined || !path.isAbsolute(outDirectory)) {
throw new Error("An absolute output directory is required.");
}
const temporaryRoot = await mkdtemp(path.join(tmpdir(), "ekklesia-native-verifier-proof-"));
try {
const moduleDirectory = path.join(temporaryRoot, "native-proof");
await cp(
path.join(repositoryRoot, "packages", "module-pack", "tests", "fixtures", "native-module"),
moduleDirectory,
{ recursive: true },
);
const packageFile = path.join(moduleDirectory, "package.json");
const packageJson = JSON.parse(await readFile(packageFile, "utf8"));
packageJson.engines = { node: ">=24.11.0 <25" };
await writeFile(packageFile, `${JSON.stringify(packageJson, null, 2)}\n`);
const modulesDirectory = path.join(moduleDirectory, "node_modules");
await mkdir(path.join(modulesDirectory, "@ekklesia"), { recursive: true });
await symlink(
await realpath(path.join(repositoryRoot, "packages", "module-pack", "node_modules", "sharp")),
path.join(modulesDirectory, "sharp"),
);
await symlink(
await realpath(
path.join(
repositoryRoot,
"packages",
"module-pack",
"node_modules",
"@ekklesia",
"module-sdk",
),
),
path.join(modulesDirectory, "@ekklesia", "module-sdk"),
);
const result = await packModule({
licenseFile: path.join(repositoryRoot, "LICENSE"),
moduleDirectory,
outDirectory,
sourceRevision: "0".repeat(40),
target: "linux-x64-glibc",
});
process.stdout.write(`${path.basename(result.archivePath)}\n`);
} finally {
await rm(temporaryRoot, { force: true, recursive: true });
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-sdk",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1
View File
@@ -37,6 +37,7 @@ export {
isDevicePrincipal,
isModuleHttpMethod,
isModuleRouteBodyMode,
isModuleStorageError,
isValidModuleStorageKey,
isValidModuleStorageReadUrlFilename,
MODULE_HTTP_BODY_MODES,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-ui-build",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-ui-host",
"version": "0.2.2",
"version": "0.2.5",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-ui-sdk",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/typescript-config",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"files": [
"base.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/ui",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+191
View File
@@ -0,0 +1,191 @@
import {
type FormEvent,
type ReactElement,
type ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react";
import { Button } from "./controls.js";
import { Dialog } from "./surfaces.js";
export type ConfirmationTone = "default" | "destructive";
export interface ConfirmationDialogProps {
readonly open: boolean;
/** The question, as the dialog's accessible name: "Delete Foyer-1?" */
readonly title: ReactNode;
/** What will happen, announced with the name: name the resource and the consequence. */
readonly description?: ReactNode;
/** Anything more the person should weigh — whether it can be undone, what else it affects. */
readonly children?: ReactNode;
/** The confirming action, named for what it does: "Delete display", never "OK". */
readonly confirmLabel: string;
readonly cancelLabel?: string;
/**
* `destructive` for an action whose effect matters. It is marked by a symbol and wording as well
* as by colour, and focus starts on Cancel rather than on the action. The consuming feature
* decides the severity; this element never upgrades or downgrades it.
*/
readonly tone?: ConfirmationTone;
/**
* Typed confirmation, for the most serious actions (DESIGN.md §8.2): the exact phrase the person
* must type before Confirm is enabled — `DELETE`, or the resource's own name. Leave it unset for
* every action that does not need it.
*/
readonly requiredPhrase?: string;
/** The label of the typed-confirmation field. Default: "Type <phrase> to confirm". */
readonly requiredPhraseLabel?: ReactNode;
/**
* The confirmed action is in progress: both actions are disabled, Escape does nothing, and the
* dialog is marked busy, so the person cannot leave it half-done. The caller closes it when done.
*/
readonly busy?: boolean;
readonly onConfirm: () => void;
readonly onCancel: () => void;
readonly "data-testid"?: string;
}
/**
* Whether a typed value matches a required phrase.
*
* Exact and case-sensitive, because typing it is meant to be deliberate — `delete` is not `DELETE`.
* Only what a phone keyboard adds without being asked is forgiven: whitespace around the value, and
* a different Unicode composition of the same characters (NFC).
*/
export function matchesRequiredPhrase(value: string, phrase: string): boolean {
return value.trim().normalize("NFC") === phrase.trim().normalize("NFC");
}
/**
* The product-owned confirmation every Ekklesia surface uses instead of `window.confirm` (DESIGN.md
* §8, ADR 0059).
*
* Three severities from one element, chosen by the caller: a simple confirmation; a destructive one;
* and a destructive one that also requires a typed phrase. It is a {@link Dialog}: modal, named by
* its title and described by its description, with focus trapped inside and returned to the control
* that opened it. Cancel is always present and always safe. Enter confirms only once a required
* phrase matches.
*/
export function ConfirmationDialog(props: ConfirmationDialogProps): ReactElement | null {
const tone = props.tone ?? "default";
const busy = props.busy ?? false;
const phraseId = useId();
const phraseHintId = useId();
const [typed, setTyped] = useState("");
const phraseRef = useRef<HTMLInputElement | null>(null);
const cancelRef = useRef<HTMLButtonElement | null>(null);
const confirmRef = useRef<HTMLButtonElement | null>(null);
const requiresPhrase = props.requiredPhrase !== undefined;
const phraseMatches =
props.requiredPhrase === undefined || matchesRequiredPhrase(typed, props.requiredPhrase);
const canConfirm = phraseMatches && !busy;
// A phrase typed for one resource must never carry over to the next time the dialog opens.
useEffect(() => {
if (!props.open) {
setTyped("");
}
}, [props.open]);
// A required phrase starts at its field; a destructive action starts on the safe choice; a simple
// confirmation starts on the action itself.
const initialFocusRef = requiresPhrase
? phraseRef
: tone === "destructive"
? cancelRef
: confirmRef;
const cancel = (): void => {
if (busy) {
return;
}
setTyped("");
props.onCancel();
};
const submit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
if (canConfirm) {
props.onConfirm();
}
};
return (
<Dialog
{...(tone === "destructive" ? { className: "ek-confirmation--destructive" } : {})}
{...(props["data-testid"] === undefined ? {} : { "data-testid": props["data-testid"] })}
{...(props.description === undefined ? {} : { description: props.description })}
dismissible={!busy}
initialFocusRef={initialFocusRef}
onDismiss={cancel}
open={props.open}
title={
tone === "destructive" ? (
<>
<span aria-hidden="true" className="ek-confirmation__symbol" />
{props.title}
</>
) : (
props.title
)
}
>
<form aria-busy={busy || undefined} className="ek-confirmation" onSubmit={submit}>
{props.children}
{props.requiredPhrase === undefined ? undefined : (
<div className="ek-field">
<label className="ek-field__label" htmlFor={phraseId}>
{props.requiredPhraseLabel ?? (
<>
Type <strong className="ek-confirmation__phrase">{props.requiredPhrase}</strong>{" "}
to confirm
</>
)}
</label>
<input
aria-describedby={phraseHintId}
aria-invalid={typed.length > 0 && !phraseMatches ? true : undefined}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="ek-input ek-focusable"
disabled={busy}
id={phraseId}
onChange={(event) => setTyped(event.currentTarget.value)}
ref={phraseRef}
spellCheck={false}
type="text"
value={typed}
/>
<span aria-live="polite" className="ek-field__hint" id={phraseHintId}>
{phraseMatches
? "Matches. You can confirm."
: typed.length === 0
? "Confirm stays unavailable until this matches exactly."
: "Does not match yet. It must match exactly, including capitals."}
</span>
</div>
)}
<div className="ek-dialog__actions">
<Button disabled={busy} onClick={cancel} ref={cancelRef}>
{props.cancelLabel ?? "Cancel"}
</Button>
<Button
aria-describedby={requiresPhrase && !phraseMatches ? phraseHintId : undefined}
disabled={!canConfirm}
ref={confirmRef}
type="submit"
variant={tone === "destructive" ? "danger" : "primary"}
>
{tone === "destructive" ? (
<span aria-hidden="true" className="ek-confirmation__symbol" />
) : undefined}
{props.confirmLabel}
</Button>
</div>
</form>
</Dialog>
);
}
+173 -20
View File
@@ -1,4 +1,4 @@
import type { ReactElement, ReactNode } from "react";
import { type CSSProperties, type ReactElement, type ReactNode, useId } from "react";
import { classNames } from "./class-names.js";
/**
@@ -48,6 +48,17 @@ export interface ToastProps {
readonly title: ReactNode;
readonly className?: string;
readonly children?: ReactNode;
/**
* Whether this toast is its own live region. Default `true`, for a toast a caller renders on its
* own. A {@link ToastProvider} renders toasts inside live regions it already owns and turns this
* off, so nothing is announced twice.
*/
readonly announce?: boolean;
/** Renders a dismiss control. */
readonly onDismiss?: () => void;
/** The dismiss control's accessible name. Default "Dismiss notification". */
readonly dismissLabel?: string;
readonly "data-toast-id"?: string;
}
export interface InlineNoticeProps {
@@ -79,27 +90,40 @@ export function InlineNotice(props: InlineNoticeProps): ReactElement {
}
/**
* One notification's presentation — and only its presentation.
* One notification's presentation.
*
* There is deliberately no toast *manager*: no queue, no timeout, no global store, no imperative
* `toast()` function. Queuing policy belongs to whichever application is rendering, and a
* package-level singleton would be shared mutable state reaching across every isolated artifact on
* a page. A caller renders these where it wants them, for as long as it wants them.
* The queue, timing, and placement belong to {@link ToastProvider} (ADR 0059), which is per React
* tree rather than a package singleton: a module-level store would be shared mutable state reaching
* across every isolated artifact on a page. A caller may still render one of these on its own.
*/
export function Toast(props: ToastProps): ReactElement {
const tone = props.tone ?? "info";
const announce = props.announce ?? true;
return (
<div
aria-live={tone === "danger" ? "assertive" : "polite"}
aria-live={announce ? (tone === "danger" ? "assertive" : "polite") : undefined}
className={classNames("ek-toast", `ek-toast--${tone}`, props.className)}
role="status"
data-toast-id={props["data-toast-id"]}
role={announce ? "status" : undefined}
>
<div className="ek-toast__title">
<span aria-hidden="true">{statusSymbols[tone]} </span>
{props.title}
<div className="ek-toast__content">
<div className="ek-toast__title">
<span aria-hidden="true">{statusSymbols[tone]} </span>
{props.title}
</div>
{props.children === undefined ? undefined : (
<div className="ek-toast__body">{props.children}</div>
)}
</div>
{props.children === undefined ? undefined : (
<div className="ek-toast__body">{props.children}</div>
{props.onDismiss === undefined ? undefined : (
<button
aria-label={props.dismissLabel ?? "Dismiss notification"}
className="ek-button ek-button--quiet ek-button--sm ek-focusable ek-toast__dismiss"
onClick={props.onDismiss}
type="button"
>
<span aria-hidden="true">✕</span>
</button>
)}
</div>
);
@@ -143,28 +167,157 @@ export function EmptyState(props: EmptyStateProps): ReactElement {
}
export interface ErrorStateProps {
/** What failed, in plain words: "The displays could not be loaded." */
readonly title: ReactNode;
/** What the person can do about it, and whether trying again makes sense. */
readonly description?: ReactNode;
/** A retry control, or whatever else the caller can honestly offer. */
/** One recovery control. Kept for existing callers; prefer `actions`. */
readonly action?: ReactNode;
/**
* Recovery and remedy controls — Retry, a link to the setting that fixes it. A caller that knows
* the viewer can administer the problem passes the administrator's remedies here as well; the
* element has no notion of roles.
*/
readonly actions?: ReactNode;
/**
* Technical detail for whoever needs it: an error code, the failing step, a likely cause. Plain
* text, collapsed behind a disclosure, so it never dominates the message. Rendered as text — markup
* in it is shown, never interpreted — with control characters removed and its length bounded.
*/
readonly details?: string;
/** The disclosure's label. Default "Technical details". */
readonly detailsLabel?: string;
/** A diagnostic id that can be quoted to an administrator or found in a log, when it is safe to
* show. Shown inside the details. */
readonly reference?: string;
/**
* Whether the error is announced when it appears (`role="alert"`). Default `true`. Turn it off for
* an error that is part of a page's initial content rather than a change the person caused.
*/
readonly announce?: boolean;
readonly className?: string;
}
/** Technical detail is bounded so an enormous server message cannot bury the page. */
export const ERROR_DETAILS_MAX_LENGTH = 2000;
/**
* The failure a person sees.
* Makes an arbitrary string safe to show as technical detail: strips control characters (keeping
* line breaks and tabs), trims, and bounds its length. React already renders it as text, so markup
* cannot execute; this keeps it legible.
*/
export function errorDetailsText(value: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: removing control characters is the point.
const cleaned = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "").trim();
return cleaned.length > ERROR_DETAILS_MAX_LENGTH
? `${cleaned.slice(0, ERROR_DETAILS_MAX_LENGTH)}…`
: cleaned;
}
/**
* The failure a person sees (DESIGN.md §7.3): what failed, what they can do, and — only if they want
* it — the technical detail.
*
* Callers pass a fixed, human message. **Never** pass a caught exception, a stack, a URL, a
* filesystem path, or a raw server response: every Ekklesia error boundary is sanitized, and this
* component is where that discipline becomes visible to a user.
* The message is the caller's fixed, human wording. **Never** pass a caught exception or its stack:
* `details` takes a string, chosen deliberately — a sanitized error code, the step that failed —
* and stays collapsed until opened.
*/
export function ErrorState(props: ErrorStateProps): ReactElement {
const details = props.details === undefined ? "" : errorDetailsText(props.details);
const reference = props.reference === undefined ? "" : errorDetailsText(props.reference);
const announce = props.announce ?? true;
return (
<div className={classNames("ek-message", "ek-message--error", props.className)} role="alert">
<div
className={classNames("ek-message", "ek-message--error", props.className)}
role={announce ? "alert" : undefined}
>
<p className="ek-message__title">{props.title}</p>
{props.description === undefined ? undefined : (
<p className="ek-message__body">{props.description}</p>
)}
{props.action}
{props.action === undefined && props.actions === undefined ? undefined : (
<div className="ek-message__actions">
{props.action}
{props.actions}
</div>
)}
{details === "" && reference === "" ? undefined : (
<details className="ek-message__details">
<summary className="ek-focusable">{props.detailsLabel ?? "Technical details"}</summary>
{details === "" ? undefined : <pre className="ek-message__details-text">{details}</pre>}
{reference === "" ? undefined : (
<p className="ek-message__reference">
Reference: <code>{reference}</code>
</p>
)}
</details>
)}
</div>
);
}
export interface SkeletonProps {
/** `text` is one line of text; `block` an image, chart, or card; `circle` an avatar. */
readonly shape?: "text" | "block" | "circle";
/** Any CSS length. Text defaults to the full width; a circle is as wide as it is tall. */
readonly width?: string;
readonly height?: string;
readonly className?: string;
}
/**
* A placeholder in the shape of content that is on its way (DESIGN.md §7.1), for content whose
* shape is known. Decorative: hidden from assistive technology, which hears its
* {@link SkeletonGroup}'s label instead. It pulses gently, and not at all for a person who asked
* their system for reduced motion. There is no progress in it, because there is none to report.
*/
export function Skeleton(props: SkeletonProps): ReactElement {
const shape = props.shape ?? "text";
const style: CSSProperties = {};
if (props.width !== undefined) {
style.width = props.width;
}
if (props.height !== undefined) {
style.height = props.height;
}
if (shape === "circle" && props.width !== undefined && props.height === undefined) {
style.height = props.width;
}
return (
<span
aria-hidden="true"
className={classNames("ek-skeleton", `ek-skeleton--${shape}`, props.className)}
style={style}
/>
);
}
export interface SkeletonGroupProps {
/** What is loading, announced once: "Loading displays". */
readonly label: string;
readonly children?: ReactNode;
readonly className?: string;
}
/**
* The loading region a set of {@link Skeleton}s stands in for. It is `aria-busy` and announces its
* label once, politely, rather than every placeholder; when the real content replaces the group,
* the busy region is gone with it.
*/
export function SkeletonGroup(props: SkeletonGroupProps): ReactElement {
const labelId = useId();
return (
<div
aria-busy="true"
aria-labelledby={labelId}
aria-live="polite"
className={classNames("ek-skeleton-group", props.className)}
role="status"
>
<span className="ek-visually-hidden" id={labelId}>
{props.label}
</span>
{props.children}
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { type KeyboardEvent, type RefObject, useCallback, useEffect, useRef } from "react";
/** Everything a keyboard can land on. Disabled controls and `tabindex="-1"` are skipped. */
const FOCUSABLE = [
"a[href]",
"area[href]",
"button:not([disabled])",
"input:not([disabled]):not([type='hidden'])",
"select:not([disabled])",
"textarea:not([disabled])",
"summary",
"[contenteditable='true']",
"[tabindex]:not([tabindex='-1'])",
].join(", ");
/** The focused element as seen from the container's own root: its shadow root inside an isolated
* module artifact (ADR 0040), where the document would only report the shadow host, or the document. */
function activeWithin(container: HTMLElement): Element | null {
const root = container.getRootNode() as Document | ShadowRoot;
return root.activeElement ?? null;
}
function focusableWithin(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(element) => !element.closest("[inert]"),
);
}
export interface FocusTrapOptions {
/** Where focus starts when the trap activates. Without one, the container itself. */
readonly initialFocus?: RefObject<HTMLElement | null>;
}
/**
* Keeps keyboard focus inside a modal surface while it is open, and puts it back afterwards.
*
* - On activation, remembers what had focus and moves focus to `initialFocus` (or the container).
* - While active, Tab from the last focusable element wraps to the first and Shift+Tab from the
* first wraps to the last, so focus cannot walk out into the page behind.
* - On deactivation or unmount, returns focus to whatever had it before — the control that opened
* the surface — if that control is still in the document.
*
* Returns the `onKeyDown` handler the container must carry. It works inside an isolated module root
* as well as in a document: it only ever looks inside its own container and at the container's own
* root node, never at a global. This is the one focus trap ADR 0039 deferred until the platform needed
* it (ADR 0059); every modal surface in `@ekklesia/ui` uses it.
*/
export function useFocusTrap(
containerRef: RefObject<HTMLElement | null>,
active: boolean,
options: FocusTrapOptions = {},
): (event: KeyboardEvent<HTMLElement>) => void {
const { initialFocus } = options;
const restoreTo = useRef<HTMLElement | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!active || !container) {
return;
}
const previous = activeWithin(container);
restoreTo.current =
previous && previous !== container.ownerDocument.body && "focus" in previous
? (previous as HTMLElement)
: null;
(initialFocus?.current ?? container).focus();
return () => {
const target = restoreTo.current;
restoreTo.current = null;
if (target?.isConnected) {
target.focus();
}
};
}, [active, containerRef, initialFocus]);
return useCallback(
(event: KeyboardEvent<HTMLElement>) => {
const container = containerRef.current;
if (!active || event.key !== "Tab" || !container) {
return;
}
const focusable = focusableWithin(container);
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) {
event.preventDefault();
container.focus();
return;
}
const current = activeWithin(container);
const outside = !(current instanceof Node) || !container.contains(current);
if (event.shiftKey && (current === first || current === container || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
},
[active, containerRef],
);
}
+18
View File
@@ -43,6 +43,11 @@ export {
type TableRowProps,
type TableSectionProps,
} from "./data.js";
export {
ConfirmationDialog,
type ConfirmationDialogProps,
type ConfirmationTone,
} from "./confirmation.js";
export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js";
export {
Badge,
@@ -56,6 +61,10 @@ export {
type InlineNoticeProps,
LoadingState,
type LoadingStateProps,
Skeleton,
SkeletonGroup,
type SkeletonGroupProps,
type SkeletonProps,
Toast,
type ToastProps,
ToastStack,
@@ -75,8 +84,17 @@ export {
Panel,
type PanelProps,
PageHeader,
type PageHeaderBack,
type PageHeaderProps,
type TabDefinition,
Tabs,
type TabsProps,
} from "./surfaces.js";
export {
type ToastApi,
type ToastOptions,
ToastProvider,
type ToastProviderProps,
type ToastTone,
useToasts,
} from "./toasts.js";
+172 -1
View File
@@ -595,14 +595,185 @@ export const EKKLESIA_UI_STYLESHEET = `
width: ${designToken("spacing.xl")};
}
.ek-visually-hidden {
border: 0;
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
.ek-dialog__description {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
.ek-confirmation {
display: grid;
gap: ${designToken("spacing.lg")};
margin: 0;
}
/* A destructive confirmation says so with a symbol and a heavier frame, never by colour alone. */
.ek-confirmation--destructive {
border-color: ${designToken("status.danger-border")};
border-width: calc(${designToken("border.width")} * 2);
}
.ek-confirmation__symbol::before {
content: ${designToken("status.warning-symbol")};
font-weight: ${designToken("typography.weight-bold")};
margin-inline-end: ${designToken("spacing.xs")};
}
.ek-confirmation__phrase {
font-family: ${designToken("typography.family-mono")};
white-space: pre-wrap;
}
.ek-toast-region {
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.sm")};
left: 50%;
max-width: calc(100vw - (${designToken("spacing.md")} * 2));
pointer-events: none;
position: fixed;
top: ${designToken("spacing.lg")};
transform: translateX(-50%);
width: 28rem;
z-index: 50;
}
.ek-toast-region__list {
display: grid;
gap: ${designToken("spacing.sm")};
}
.ek-toast-region .ek-toast {
animation: ek-toast-enter 160ms ease-out;
pointer-events: auto;
}
.ek-toast {
align-items: start;
display: flex;
gap: ${designToken("spacing.sm")};
}
.ek-toast__content {
display: grid;
flex: 1 1 auto;
gap: ${designToken("spacing.2xs")};
min-width: 0;
}
.ek-toast__dismiss {
flex: 0 0 auto;
min-width: ${designToken("control.height-sm")};
}
.ek-message__actions {
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")};
justify-content: center;
}
.ek-message__details {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-xs")};
max-width: 100%;
text-align: start;
width: 100%;
}
.ek-message__details summary {
cursor: pointer;
min-height: ${designToken("control.height-sm")};
padding-block: ${designToken("spacing.2xs")};
}
.ek-message__details-text {
background: ${designToken("background.inset")};
border-radius: ${designToken("radius.sm")};
font-family: ${designToken("typography.family-mono")};
margin: ${designToken("spacing.xs")} 0 0;
max-height: 16rem;
overflow: auto;
padding: ${designToken("spacing.sm")};
white-space: pre-wrap;
word-break: break-word;
}
.ek-message__reference {
margin: ${designToken("spacing.xs")} 0 0;
}
.ek-page-header__back {
justify-self: start;
margin-inline-start: calc(-1 * ${designToken("control.padding-inline")});
text-decoration: none;
}
.ek-skeleton-group {
display: grid;
gap: ${designToken("spacing.sm")};
}
.ek-skeleton {
animation: ek-skeleton-pulse 1.6s ease-in-out infinite;
background: ${designToken("background.inset")};
border-radius: ${designToken("radius.sm")};
display: block;
}
.ek-skeleton--text {
height: 1em;
width: 100%;
}
.ek-skeleton--block {
height: 6rem;
width: 100%;
}
.ek-skeleton--circle {
border-radius: ${designToken("radius.pill")};
height: 2.5rem;
width: 2.5rem;
}
@keyframes ek-spin {
to {
transform: rotate(360deg);
}
}
@keyframes ek-skeleton-pulse {
50% {
opacity: 0.55;
}
}
@keyframes ek-toast-enter {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
}
/* Every animation in these elements stops for a person who asked for reduced motion; nothing in
them depends on movement to be understood. */
@media (prefers-reduced-motion: reduce) {
.ek-spinner {
.ek-spinner,
.ek-skeleton,
.ek-toast-region .ek-toast {
animation: none;
}
}
+126 -23
View File
@@ -1,29 +1,101 @@
import {
type HTMLAttributes,
type MouseEvent,
type ReactElement,
type ReactNode,
useEffect,
type RefObject,
useId,
useRef,
} from "react";
import { classNames } from "./class-names.js";
import { useFocusTrap } from "./focus-trap.js";
/** Containers: the panel a screen is built from, the dialog that interrupts it, and the tab strip
* that divides it. */
/**
* A page's Back action. The caller decides where Back goes: a host that knows its own routes
* validates any return target before handing it over (ADR 0059); this element only renders it.
*/
export interface PageHeaderBack {
/** Visible and accessible. Name the destination where it helps: "Back to Signage". */
readonly label: string;
/** When Back is a real place, its address: rendered as a link, so it can be opened in a new tab
* and is announced as a link. Without one, Back is a button. */
readonly href?: string;
/** Called on an ordinary activation. A click with a modifier key or the middle button on a link
* is left to the browser. */
readonly onBack: () => void;
}
export interface PageHeaderProps {
readonly title: ReactNode;
readonly description?: ReactNode;
readonly actions?: ReactNode;
readonly eyebrow?: ReactNode;
/** The Back action of a nested page (DESIGN.md §4.8): Back plus the page title is the baseline;
* breadcrumbs are not part of it. */
readonly back?: PageHeaderBack;
readonly className?: string;
}
/** The consistent top-level identity and primary-action region for an Ekklesia screen. */
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
return (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
);
}
function BackAction(props: { readonly back: PageHeaderBack }): ReactElement {
const { back } = props;
const content = (
<>
<span aria-hidden="true" className="ek-page-header__back-symbol">
←
</span>
{back.label}
</>
);
const className = "ek-button ek-button--quiet ek-button--sm ek-focusable ek-page-header__back";
if (back.href !== undefined) {
return (
<a
className={className}
href={back.href}
onClick={(event) => {
if (isBrowserHandledClick(event)) {
return;
}
event.preventDefault();
back.onBack();
}}
>
{content}
</a>
);
}
return (
<button className={className} onClick={back.onBack} type="button">
{content}
</button>
);
}
/**
* The consistent top-level identity and primary-action region for an Ekklesia screen.
*
* The title is the page's one `h1`; a section within the page is a `Panel` with its own `h2`. A
* nested page puts its Back action above the title, and page-level actions sit opposite it.
*/
export function PageHeader(props: PageHeaderProps): ReactElement {
return (
<header className={classNames("ek-page-header", props.className)}>
<div className="ek-page-header__copy">
{props.back === undefined ? undefined : <BackAction back={props.back} />}
{props.eyebrow === undefined ? undefined : (
<span className="ek-page-header__eyebrow">{props.eyebrow}</span>
)}
@@ -98,12 +170,23 @@ export function Panel(props: PanelProps): ReactElement {
export interface DialogProps {
readonly open: boolean;
/** The dialog's accessible name, rendered as its heading. */
readonly title: ReactNode;
/** A short statement of what the dialog is about, announced with its name (`aria-describedby`). */
readonly description?: ReactNode;
readonly onDismiss: () => void;
/**
* Whether Escape dismisses the dialog. Default `true`. A caller turns it off while something is
* in progress that dismissing would leave half-done.
*/
readonly dismissible?: boolean;
/** Where focus starts. Without one, the dialog itself, so its name and description are read. */
readonly initialFocusRef?: RefObject<HTMLElement | null>;
/** Buttons for the dialog's decisions, rendered in a trailing-aligned row. */
readonly actions?: ReactNode;
readonly className?: string;
readonly children?: ReactNode;
readonly "data-testid"?: string;
}
/**
@@ -112,43 +195,58 @@ export interface DialogProps {
* Portalling is what a document-scoped component library does. It is exactly wrong here: a module
* artifact's dialog portalled to the host document would escape the artifact's isolated root, lose
* its styles, and render over the Dashboard — which is the leak this package exists to prevent. The
* scrim is `position: fixed`, so it still covers the viewport from wherever it is rendered.
* scrim is `position: fixed`, so it still covers the viewport from wherever it is rendered, and it
* takes every pointer event, so nothing behind the dialog can be clicked.
*
* Focus moves to the dialog when it opens and `Escape` dismisses it. A full focus trap is
* deliberately not implemented here rather than implemented badly; when the platform needs one it
* belongs in one place, tested, not sprinkled through call sites.
* While open, keyboard focus is trapped inside it ({@link useFocusTrap}): Tab and Shift+Tab wrap,
* and focus returns to the control that opened it when it closes. Escape dismisses it unless the
* caller says that is not safe right now. Escape is handled on the dialog itself and goes no
* further, so a drawer or menu the dialog was opened from does not close with it. Assistive
* technology is told it is modal (`aria-modal`), named by its title, and described by its
* description.
*/
export function Dialog(props: DialogProps): ReactElement | null {
const titleId = useId();
const descriptionId = useId();
const dialogRef = useRef<HTMLDivElement | null>(null);
const { onDismiss, open } = props;
useEffect(() => {
if (!open) {
return;
}
dialogRef.current?.focus();
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
event.stopPropagation();
onDismiss();
}
};
const owner = dialogRef.current?.ownerDocument;
owner?.addEventListener("keydown", onKeyDown);
return () => owner?.removeEventListener("keydown", onKeyDown);
}, [onDismiss, open]);
const dismissible = props.dismissible ?? true;
const trapKeyDown = useFocusTrap(dialogRef, open, {
...(props.initialFocusRef ? { initialFocus: props.initialFocusRef } : {}),
});
if (!open) {
return null;
}
return (
<div className="ek-dialog__scrim">
<div
className="ek-dialog__scrim"
// The scrim is the barrier between the dialog and the page: a press on it reaches nothing
// behind it and does not dismiss the dialog, which only ever closes deliberately.
onPointerDown={(event) => {
if (event.target === event.currentTarget) {
event.preventDefault();
}
}}
>
<div
aria-describedby={props.description === undefined ? undefined : descriptionId}
aria-labelledby={titleId}
aria-modal="true"
className={classNames("ek-dialog", "ek-focusable", props.className)}
data-testid={props["data-testid"]}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (dismissible) {
onDismiss();
}
return;
}
trapKeyDown(event);
}}
ref={dialogRef}
role="dialog"
tabIndex={-1}
@@ -156,6 +254,11 @@ export function Dialog(props: DialogProps): ReactElement | null {
<h2 className="ek-dialog__title" id={titleId}>
{props.title}
</h2>
{props.description === undefined ? undefined : (
<div className="ek-dialog__description" id={descriptionId}>
{props.description}
</div>
)}
{props.children}
{props.actions === undefined ? undefined : (
<div className="ek-dialog__actions">{props.actions}</div>
+343
View File
@@ -0,0 +1,343 @@
import {
createContext,
type FocusEvent,
type ReactElement,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Toast } from "./feedback.js";
/** The four severities a toast can carry. `danger` is an error. */
export type ToastTone = "success" | "info" | "warning" | "danger";
export interface ToastOptions {
readonly tone?: ToastTone;
/** What happened, in a few words. Also names the toast's dismiss control. */
readonly title: string;
readonly description?: string;
/**
* How long it stays, in milliseconds, or `"persistent"` to stay until dismissed. Defaults by
* severity: an error stays until dismissed; anything else lasts long enough to read, and never
* less than three seconds (DESIGN.md §7.4).
*/
readonly duration?: number | "persistent";
}
export interface ToastApi {
/** Shows a toast and returns its id. An identical toast already showing is refreshed instead. */
show(options: ToastOptions): string;
dismiss(id: string): void;
}
/** DESIGN.md §7.4's floor: no toast disappears in under about three seconds. */
export const TOAST_MINIMUM_DURATION_MS = 3000;
const DEFAULT_DURATION_MS: Readonly<Record<Exclude<ToastTone, "danger">, number>> = Object.freeze({
info: 4000,
success: 4000,
warning: 6000,
});
const READING_MS_PER_CHARACTER = 50;
const LONGEST_TIMED_DURATION_MS = 12_000;
const DEFAULT_MAX_VISIBLE = 3;
/**
* How long a toast stays: its requested or default duration, but never less than it takes to read.
* Longer messages stay longer, up to a ceiling; an error stays until dismissed unless its caller
* deliberately gives it a duration (a routine validation error, say).
*/
export function toastDuration(options: ToastOptions): number | "persistent" {
const tone = options.tone ?? "info";
const requested =
options.duration ?? (tone === "danger" ? "persistent" : DEFAULT_DURATION_MS[tone]);
if (requested === "persistent") {
return "persistent";
}
const characters = options.title.length + (options.description?.length ?? 0);
const readingTime = Math.min(
LONGEST_TIMED_DURATION_MS,
TOAST_MINIMUM_DURATION_MS + characters * READING_MS_PER_CHARACTER,
);
return Math.max(TOAST_MINIMUM_DURATION_MS, requested, readingTime);
}
interface ToastRecord {
readonly id: string;
readonly tone: ToastTone;
readonly title: string;
readonly description?: string;
readonly duration: number | "persistent";
readonly key: string;
}
/** A running or paused countdown. `remaining` is what is left when it was last started. */
interface ToastTimer {
handle: ReturnType<typeof setTimeout> | undefined;
remaining: number;
started: number;
}
const ToastContext = createContext<ToastApi | null>(null);
/**
* The toast API of the nearest {@link ToastProvider}. Throws outside one, because a toast shown
* nowhere is a silent failure.
*/
export function useToasts(): ToastApi {
const api = useContext(ToastContext);
if (!api) {
throw new Error("useToasts must be used inside a ToastProvider.");
}
return api;
}
export interface ToastProviderProps {
readonly children?: ReactNode;
/** Names the notification region for assistive technology. */
readonly label?: string;
/** How many non-error toasts show at once. Errors are never displaced to make room. */
readonly maxVisible?: number;
}
/**
* The toast system of one React tree (ADR 0059): a top-center region and the queue behind it.
*
* **Per tree, never a package singleton.** A module mounts in its own isolated root (ADR 0040) with
* its own React tree; a module-level store shared through this package would be mutable state
* reaching across every artifact on the page. Each provider owns its toasts, its timers, and its
* region, and is torn down with its tree — every timer with it.
*
* **Calm under load.** At most `maxVisible` routine toasts show; the oldest routine one makes room.
* An error is never displaced by a routine toast, and stays until dismissed by default. Showing a
* toast identical to one already visible refreshes it rather than stacking a copy. Newest is on top.
*
* **Readable and accessible.** Timers pause while the pointer is over the region or focus is inside
* it (WCAG 2.2 §2.2.1). The region's two live regions are mounted before any toast arrives, so
* assistive technology announces what is added: routine toasts politely, errors assertively. Every
* toast has a dismiss control named for it, and Escape on a focused toast dismisses it; when the
* last one goes, focus returns to where it was before it entered the region.
*/
export function ToastProvider(props: ToastProviderProps): ReactElement {
const maxVisible = Math.max(1, props.maxVisible ?? DEFAULT_MAX_VISIBLE);
const [toasts, setToasts] = useState<readonly ToastRecord[]>([]);
// The synchronous source of truth: `show` must know at once whether a toast is a duplicate and
// what its id is, which a state updater (run later, during render) cannot tell it.
const current = useRef<readonly ToastRecord[]>([]);
const commit = useCallback((next: readonly ToastRecord[]) => {
current.current = next;
setToasts(next);
}, []);
const timers = useRef(new Map<string, ToastTimer>());
const paused = useRef(false);
const nextId = useRef(0);
const focusBeforeRegion = useRef<HTMLElement | null>(null);
const regionRef = useRef<HTMLDivElement | null>(null);
const clearTimer = useCallback((id: string) => {
const timer = timers.current.get(id);
if (timer?.handle !== undefined) {
clearTimeout(timer.handle);
}
timers.current.delete(id);
}, []);
const dismiss = useCallback(
(id: string) => {
clearTimer(id);
if (current.current.some((toast) => toast.id === id)) {
commit(current.current.filter((toast) => toast.id !== id));
}
},
[clearTimer, commit],
);
const startTimer = useCallback(
(id: string, remaining: number) => {
const existing = timers.current.get(id);
if (existing?.handle !== undefined) {
clearTimeout(existing.handle);
}
const timer: ToastTimer = { handle: undefined, remaining, started: Date.now() };
if (!paused.current) {
timer.handle = setTimeout(() => dismiss(id), remaining);
}
timers.current.set(id, timer);
},
[dismiss],
);
const show = useCallback(
(options: ToastOptions): string => {
const tone = options.tone ?? "info";
const duration = toastDuration(options);
const key = JSON.stringify([tone, options.title, options.description ?? ""]);
const duplicate = current.current.find((toast) => toast.key === key);
if (duplicate) {
// Showing it again restarts its time rather than stacking a copy.
if (duplicate.duration !== "persistent") {
startTimer(duplicate.id, duplicate.duration);
}
return duplicate.id;
}
nextId.current += 1;
const id = `ek-toast-${nextId.current}`;
const record: ToastRecord = {
duration,
id,
key,
title: options.title,
tone,
...(options.description === undefined ? {} : { description: options.description }),
};
const next = [record, ...current.current];
const routine = next.filter((toast) => toast.tone !== "danger");
const overflow = new Set(routine.slice(maxVisible).map((toast) => toast.id));
for (const removed of overflow) {
clearTimer(removed);
}
commit(next.filter((toast) => !overflow.has(toast.id)));
if (duration !== "persistent") {
startTimer(id, duration);
}
return id;
},
[clearTimer, commit, maxVisible, startTimer],
);
const pause = useCallback(() => {
if (paused.current) {
return;
}
paused.current = true;
const now = Date.now();
for (const timer of timers.current.values()) {
if (timer.handle !== undefined) {
clearTimeout(timer.handle);
timer.handle = undefined;
timer.remaining = Math.max(0, timer.remaining - (now - timer.started));
}
}
}, []);
const resume = useCallback(() => {
if (!paused.current) {
return;
}
paused.current = false;
for (const [id, timer] of [...timers.current.entries()]) {
startTimer(id, timer.remaining);
}
}, [startTimer]);
// Every timer dies with the provider.
useEffect(() => {
const active = timers.current;
return () => {
for (const timer of active.values()) {
if (timer.handle !== undefined) {
clearTimeout(timer.handle);
}
}
active.clear();
};
}, []);
const dismissFromRegion = (id: string): void => {
const remaining = current.current.filter((toast) => toast.id !== id);
dismiss(id);
const region = regionRef.current;
const focusWasInside =
region !== null &&
(region.getRootNode() as Document | ShadowRoot).activeElement !== null &&
region.contains((region.getRootNode() as Document | ShadowRoot).activeElement);
if (!focusWasInside) {
return;
}
// Keep a keyboard user in the region while toasts remain; otherwise return them to where they
// were before they entered it, rather than to nowhere.
if (remaining.length > 0) {
queueMicrotask(() => {
region.querySelector<HTMLElement>(".ek-toast__dismiss")?.focus();
});
} else {
const target = focusBeforeRegion.current;
focusBeforeRegion.current = null;
resume();
if (target?.isConnected) {
target.focus();
}
}
};
const api = useMemo<ToastApi>(() => ({ dismiss, show }), [dismiss, show]);
const routine = toasts.filter((toast) => toast.tone !== "danger");
const errors = toasts.filter((toast) => toast.tone === "danger");
const renderToast = (toast: ToastRecord): ReactElement => (
<Toast
announce={false}
data-toast-id={toast.id}
dismissLabel={`Dismiss notification: ${toast.title}`}
key={toast.id}
onDismiss={() => dismissFromRegion(toast.id)}
title={toast.title}
tone={toast.tone}
>
{toast.description}
</Toast>
);
return (
<ToastContext.Provider value={api}>
{props.children}
<section
aria-label={props.label ?? "Notifications"}
className="ek-toast-region"
onBlur={(event: FocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
resume();
}
}}
onFocus={(event: FocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
focusBeforeRegion.current =
event.relatedTarget instanceof HTMLElement ? event.relatedTarget : null;
}
pause();
}}
onKeyDown={(event) => {
if (event.key !== "Escape") {
return;
}
const owner = (event.target as HTMLElement).closest<HTMLElement>("[data-toast-id]");
const id = owner?.dataset.toastId;
if (id) {
event.stopPropagation();
dismissFromRegion(id);
}
}}
onMouseEnter={pause}
onMouseLeave={() => {
const region = regionRef.current;
const root = region?.getRootNode() as Document | ShadowRoot | undefined;
if (!region || !root?.activeElement || !region.contains(root.activeElement)) {
resume();
}
}}
ref={regionRef}
>
{/* Errors are announced assertively and listed first, so nothing routine can hide one. */}
<div aria-live="assertive" className="ek-toast-region__list" role="alert">
{errors.map(renderToast)}
</div>
<div aria-live="polite" className="ek-toast-region__list" role="status">
{routine.map(renderToast)}
</div>
</section>
</ToastContext.Provider>
);
}
+2 -1
View File
@@ -131,8 +131,9 @@ describe("Ekklesia elements", () => {
// dialog must not escape its isolated root.
expect(document.body.querySelector(":scope > [role='dialog']")).toBeNull();
// Focus is inside the dialog, which handles Escape itself (ADR 0059).
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
dialog?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
});
expect(onDismiss).toHaveBeenCalledTimes(1);
});
+797
View File
@@ -0,0 +1,797 @@
import { act, type ReactElement, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { matchesRequiredPhrase } from "../src/confirmation.js";
import { ERROR_DETAILS_MAX_LENGTH, errorDetailsText } from "../src/feedback.js";
import {
ConfirmationDialog,
type ConfirmationDialogProps,
Dialog,
ErrorState,
PageHeader,
Skeleton,
SkeletonGroup,
type ToastApi,
ToastProvider,
useToasts,
} from "../src/index.js";
import { EKKLESIA_UI_STYLESHEET } from "../src/styles.js";
import { TOAST_MINIMUM_DURATION_MS, toastDuration } from "../src/toasts.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
function render(node: ReactElement): void {
act(() => root.render(node));
}
function byTestId(id: string): HTMLElement {
const element = container.querySelector<HTMLElement>(`[data-testid="${id}"]`);
if (!element) {
throw new Error(`missing ${id}`);
}
return element;
}
function dialog(): HTMLElement {
const element = container.querySelector<HTMLElement>('[role="dialog"]');
if (!element) {
throw new Error("no dialog");
}
return element;
}
function buttonNamed(name: string, scope: ParentNode = container): HTMLButtonElement {
const button = [...scope.querySelectorAll("button")].find(
(candidate) => (candidate.getAttribute("aria-label") ?? candidate.textContent) === name,
);
if (!button) {
throw new Error(`no button ${name}`);
}
return button;
}
function press(element: Element, key: string, init: KeyboardEventInit = {}): KeyboardEvent {
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key, ...init });
act(() => {
element.dispatchEvent(event);
});
return event;
}
function type(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
act(() => {
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
/** A page with a control that opens a confirmation, so opening, focus return, and the background
* can all be observed. */
function ConfirmHarness(props: {
readonly onConfirm?: () => void;
readonly onCancel?: () => void;
readonly dialog?: Partial<ConfirmationDialogProps>;
}): ReactElement {
const [open, setOpen] = useState(false);
return (
<div>
<button data-testid="background" type="button">
Something behind
</button>
<button data-testid="opener" onClick={() => setOpen(true)} type="button">
Delete display
</button>
<ConfirmationDialog
confirmLabel="Delete display"
description="Foyer-1 stops showing content and must be paired again to come back."
onCancel={() => {
props.onCancel?.();
setOpen(false);
}}
onConfirm={() => {
props.onConfirm?.();
setOpen(false);
}}
open={open}
title="Delete Foyer-1?"
{...props.dialog}
/>
</div>
);
}
function openHarness(options: Parameters<typeof ConfirmHarness>[0] = {}): void {
render(<ConfirmHarness {...options} />);
const opener = byTestId("opener");
act(() => opener.focus());
act(() => opener.click());
}
describe("ConfirmationDialog", () => {
it("is closed until asked, and opens as a named, described modal dialog", () => {
render(<ConfirmHarness />);
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => byTestId("opener").click());
const element = dialog();
expect(element.getAttribute("aria-modal")).toBe("true");
expect(
container.querySelector(`#${element.getAttribute("aria-labelledby")}`)?.textContent,
).toBe("Delete Foyer-1?");
expect(
container.querySelector(`#${element.getAttribute("aria-describedby")}`)?.textContent,
).toBe("Foyer-1 stops showing content and must be paired again to come back.");
});
it("confirms with its named action, and cancels with Cancel, without the other firing", () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
openHarness({ onCancel, onConfirm });
act(() => buttonNamed("Cancel", dialog()).click());
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onConfirm).not.toHaveBeenCalled();
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => byTestId("opener").click());
act(() => buttonNamed("Delete display", dialog()).click());
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("starts a simple confirmation on its action, ready for Enter", () => {
openHarness();
expect(document.activeElement?.textContent).toBe("Delete display");
expect(document.activeElement?.className).toContain("ek-button--primary");
});
it("marks a destructive action by symbol and frame as well as colour, and starts on Cancel", () => {
openHarness({ dialog: { tone: "destructive" } });
const confirm = [...dialog().querySelectorAll("button")].find((button) =>
button.textContent?.includes("Delete display"),
);
expect(confirm?.className).toContain("ek-button--danger");
expect(confirm?.querySelector(".ek-confirmation__symbol")?.getAttribute("aria-hidden")).toBe(
"true",
);
expect(dialog().className).toContain("ek-confirmation--destructive");
expect(dialog().querySelector("h2 .ek-confirmation__symbol")).not.toBeNull();
// The safe choice has focus, so a stray Enter cancels rather than destroys.
expect(document.activeElement?.textContent).toBe("Cancel");
expect(EKKLESIA_UI_STYLESHEET).toContain(".ek-confirmation__symbol::before");
});
it("requires the exact phrase before a typed confirmation can be confirmed", () => {
const onConfirm = vi.fn();
openHarness({ dialog: { requiredPhrase: "Foyer-1", tone: "destructive" }, onConfirm });
const input = dialog().querySelector("input") as HTMLInputElement;
const confirm = [...dialog().querySelectorAll("button")].find((button) =>
button.textContent?.includes("Delete display"),
) as HTMLButtonElement;
// Focus starts at the field, which is labelled with the phrase it wants.
expect(document.activeElement).toBe(input);
expect(input.labels?.[0]?.textContent).toBe("Type Foyer-1 to confirm");
expect(input.getAttribute("autocapitalize")).toBe("off");
expect(input.getAttribute("spellcheck")).toBe("false");
expect(confirm.disabled).toBe(true);
type(input, "foyer-1");
expect(confirm.disabled).toBe(true);
expect(input.getAttribute("aria-invalid")).toBe("true");
act(() => {
(input.form as HTMLFormElement).dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
});
expect(onConfirm).not.toHaveBeenCalled();
type(input, " Foyer-1 ");
expect(confirm.disabled).toBe(false);
expect(input.getAttribute("aria-invalid")).toBeNull();
// Enter in the field submits once it matches.
act(() => {
(input.form as HTMLFormElement).dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
});
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("forgets a typed phrase when it closes", () => {
openHarness({ dialog: { requiredPhrase: "DELETE" } });
type(dialog().querySelector("input") as HTMLInputElement, "DELETE");
act(() => buttonNamed("Cancel", dialog()).click());
act(() => byTestId("opener").click());
expect((dialog().querySelector("input") as HTMLInputElement).value).toBe("");
});
it("matches a phrase exactly, forgiving only surrounding whitespace and Unicode composition", () => {
expect(matchesRequiredPhrase("DELETE", "DELETE")).toBe(true);
expect(matchesRequiredPhrase(" DELETE\n", "DELETE")).toBe(true);
expect(matchesRequiredPhrase("delete", "DELETE")).toBe(false);
expect(matchesRequiredPhrase("DELET", "DELETE")).toBe(false);
expect(matchesRequiredPhrase("DELETE DELETE", "DELETE")).toBe(false);
expect(matchesRequiredPhrase("Café", "Café")).toBe(true);
});
it("closes on Escape and returns focus to the control that opened it", () => {
const onCancel = vi.fn();
openHarness({ onCancel });
const event = press(dialog(), "Escape");
expect(event.defaultPrevented).toBe(true);
expect(onCancel).toHaveBeenCalledTimes(1);
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(document.activeElement).toBe(byTestId("opener"));
});
it("does not close on Escape or Cancel while the confirmed action is in progress", () => {
const onCancel = vi.fn();
openHarness({ dialog: { busy: true }, onCancel });
press(dialog(), "Escape");
expect(onCancel).not.toHaveBeenCalled();
expect(buttonNamed("Cancel", dialog()).disabled).toBe(true);
expect(dialog().querySelector("form")?.getAttribute("aria-busy")).toBe("true");
});
it("keeps Tab and Shift+Tab inside the dialog", () => {
openHarness({ dialog: { requiredPhrase: "DELETE" } });
const input = dialog().querySelector("input") as HTMLInputElement;
type(input, "DELETE");
const cancel = buttonNamed("Cancel", dialog());
const confirm = buttonNamed("Delete display", dialog());
act(() => confirm.focus());
expect(press(confirm, "Tab").defaultPrevented).toBe(true);
expect(document.activeElement).toBe(input);
expect(press(input, "Tab", { shiftKey: true }).defaultPrevented).toBe(true);
expect(document.activeElement).toBe(confirm);
// Between inner controls, the browser moves focus itself.
act(() => input.focus());
expect(press(input, "Tab").defaultPrevented).toBe(false);
expect(cancel).toBeTruthy();
});
it("blocks the page behind: the scrim takes the pointer and nothing behind is in the tab loop", () => {
openHarness();
const scrim = dialog().parentElement as HTMLElement;
expect(scrim.className).toBe("ek-dialog__scrim");
const pointer = new MouseEvent("pointerdown", { bubbles: true, cancelable: true });
act(() => {
scrim.dispatchEvent(pointer);
});
expect(pointer.defaultPrevented).toBe(true);
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(dialog().contains(byTestId("background"))).toBe(false);
// Tab from the last control wraps inside rather than reaching "Something behind".
const last = buttonNamed("Delete display", dialog());
act(() => last.focus());
press(last, "Tab");
expect(document.activeElement).not.toBe(byTestId("background"));
expect(dialog().contains(document.activeElement)).toBe(true);
});
it("stops Escape at the dialog, so a drawer or menu around it stays open", () => {
const outer = vi.fn();
render(
// biome-ignore lint/a11y/noStaticElementInteractions: a test stand-in for a drawer that closes on Escape.
<div
onKeyDown={(event) => {
if (event.key === "Escape") {
outer();
}
}}
>
<ConfirmHarness />
</div>,
);
act(() => byTestId("opener").click());
press(dialog(), "Escape");
expect(outer).not.toHaveBeenCalled();
});
it("is operable with the keyboard alone", () => {
const onConfirm = vi.fn();
openHarness({ dialog: { requiredPhrase: "DELETE" }, onConfirm });
const input = document.activeElement as HTMLInputElement;
type(input, "DELETE");
act(() => {
(input.form as HTMLFormElement).requestSubmit();
});
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
describe("Dialog", () => {
it("traps focus, describes itself, and returns focus when it closes", () => {
function Harness(): ReactElement {
const [open, setOpen] = useState(false);
return (
<>
<button data-testid="open" onClick={() => setOpen(true)} type="button">
Open
</button>
<Dialog
actions={
<button onClick={() => setOpen(false)} type="button">
Done
</button>
}
description="Details about the dialog."
onDismiss={() => setOpen(false)}
open={open}
title="Information"
/>
</>
);
}
render(<Harness />);
act(() => byTestId("open").focus());
act(() => byTestId("open").click());
expect(document.activeElement).toBe(dialog());
expect(
container.querySelector(`#${dialog().getAttribute("aria-describedby")}`)?.textContent,
).toBe("Details about the dialog.");
const done = buttonNamed("Done");
act(() => done.focus());
press(done, "Tab");
expect(document.activeElement).toBe(done);
act(() => done.click());
expect(document.activeElement).toBe(byTestId("open"));
});
it("ignores Escape when the caller says dismissing is not safe", () => {
const onDismiss = vi.fn();
render(<Dialog dismissible={false} onDismiss={onDismiss} open title="Saving" />);
press(dialog(), "Escape");
expect(onDismiss).not.toHaveBeenCalled();
});
});
function ToastHarness(props: { readonly onApi: (api: ToastApi) => void }): null {
props.onApi(useToasts());
return null;
}
function renderToasts(options: { readonly maxVisible?: number } = {}): ToastApi {
let api: ToastApi | undefined;
render(
<ToastProvider {...options}>
<ToastHarness
onApi={(value) => {
api = value;
}}
/>
</ToastProvider>,
);
if (!api) {
throw new Error("no toast api");
}
return api;
}
function toastTitles(): string[] {
return [...container.querySelectorAll(".ek-toast .ek-toast__title")].map(
(title) => title.textContent?.replace(/^\S+\s/u, "") ?? "",
);
}
describe("toasts", () => {
it("renders a top-center region whose live regions exist before any toast arrives", () => {
renderToasts();
const region = container.querySelector(".ek-toast-region");
expect(region?.tagName).toBe("SECTION");
expect(region?.getAttribute("aria-label")).toBe("Notifications");
expect(region?.querySelector('[role="status"][aria-live="polite"]')).not.toBeNull();
expect(region?.querySelector('[role="alert"][aria-live="assertive"]')).not.toBeNull();
expect(EKKLESIA_UI_STYLESHEET).toMatch(/\.ek-toast-region \{[^}]*position: fixed;[^}]*top:/su);
});
it("shows each severity with its symbol, errors assertively and the rest politely", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "Saved", tone: "success" });
api.show({ title: "Heads up", tone: "info" });
api.show({ title: "Storage is nearly full", tone: "warning" });
api.show({ title: "The display could not be revoked", tone: "danger" });
});
const polite = container.querySelector('[aria-live="polite"]') as HTMLElement;
const assertive = container.querySelector('[aria-live="assertive"]') as HTMLElement;
expect(assertive.textContent).toContain("The display could not be revoked");
expect(polite.textContent).not.toContain("could not be revoked");
for (const tone of ["success", "info", "warning", "danger"]) {
const toast = container.querySelector(`.ek-toast--${tone}`);
expect(toast, tone).not.toBeNull();
// Its own role would nest one live region in another.
expect(toast?.getAttribute("role"), tone).toBeNull();
expect(toast?.querySelector('[aria-hidden="true"]')?.textContent?.trim(), tone).not.toBe("");
}
});
it("dismisses a routine toast on its own once there has been time to read it", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "Saved", tone: "success" });
});
act(() => {
vi.advanceTimersByTime(TOAST_MINIMUM_DURATION_MS - 1);
});
expect(toastTitles()).toEqual(["Saved"]);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(toastTitles()).toEqual([]);
});
it("never times a toast out in under three seconds, and gives longer messages longer", () => {
const tooShort = toastDuration({ duration: 500, title: "Saved" }) as number;
expect(tooShort).toBeGreaterThanOrEqual(TOAST_MINIMUM_DURATION_MS);
expect(tooShort).toBeLessThan(TOAST_MINIMUM_DURATION_MS + 1000);
const short = toastDuration({ title: "Saved", tone: "success" }) as number;
const long = toastDuration({
description:
"The timeline was published to every screen in the Sanctuary and Foyer groups, and the next slide starts in a minute.",
title: "Published",
tone: "success",
}) as number;
expect(short).toBeGreaterThanOrEqual(TOAST_MINIMUM_DURATION_MS);
expect(long).toBeGreaterThan(short);
expect(toastDuration({ title: "Careful", tone: "warning" })).toBeGreaterThan(short);
});
it("keeps an error until it is dismissed, unless its caller gives it a duration", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "The display could not be revoked", tone: "danger" });
api.show({ duration: 4000, title: "Enter a name", tone: "danger" });
});
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(toastTitles()).toEqual(["The display could not be revoked"]);
expect(toastDuration({ title: "x", tone: "danger" })).toBe("persistent");
});
it("dismisses on its named dismiss control", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "The display could not be revoked", tone: "danger" });
});
act(() => buttonNamed("Dismiss notification: The display could not be revoked").click());
expect(toastTitles()).toEqual([]);
});
it("dismisses programmatically by the id it returned", () => {
vi.useFakeTimers();
const api = renderToasts();
let id = "";
act(() => {
id = api.show({ title: "Uploading", tone: "info" });
});
expect(id).toMatch(/^ek-toast-/u);
act(() => api.dismiss(id));
expect(toastTitles()).toEqual([]);
});
it("dismisses the focused toast on Escape and returns focus where it was", () => {
vi.useFakeTimers();
let api: ToastApi | undefined;
render(
<ToastProvider>
<button data-testid="work" type="button">
Work
</button>
<ToastHarness
onApi={(value) => {
api = value;
}}
/>
</ToastProvider>,
);
act(() => {
api?.show({ title: "Saved", tone: "success" });
});
const work = byTestId("work");
act(() => work.focus());
const dismiss = buttonNamed("Dismiss notification: Saved");
act(() => {
dismiss.dispatchEvent(new FocusEvent("focusin", { bubbles: true, relatedTarget: work }));
dismiss.focus();
});
press(dismiss, "Escape");
expect(toastTitles()).toEqual([]);
expect(document.activeElement).toBe(work);
});
it("stacks newest first, refreshes a duplicate, and never lets routine toasts displace an error", () => {
vi.useFakeTimers();
const api = renderToasts({ maxVisible: 2 });
act(() => {
api.show({ title: "Storage is unavailable", tone: "danger" });
api.show({ title: "One", tone: "info" });
api.show({ title: "Two", tone: "info" });
api.show({ title: "Three", tone: "info" });
api.show({ title: "Three", tone: "info" });
});
// Errors are listed first; then routine toasts, newest first, at most two of them.
expect(toastTitles()).toEqual(["Storage is unavailable", "Three", "Two"]);
expect(container.querySelectorAll(".ek-toast")).toHaveLength(3);
});
it("pauses while the pointer is over it, so a toast being read does not vanish", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "Saved", tone: "success" });
});
const region = container.querySelector(".ek-toast-region") as HTMLElement;
act(() => {
region.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
});
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(toastTitles()).toEqual(["Saved"]);
act(() => {
region.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
});
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(toastTitles()).toEqual([]);
});
it("clears every timer when the provider goes away", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "One", tone: "info" });
api.show({ title: "Two", tone: "warning" });
});
expect(vi.getTimerCount()).toBe(2);
act(() => root.unmount());
expect(vi.getTimerCount()).toBe(0);
root = createRoot(container);
});
it("is per React tree: outside a provider there is no toast API to reach for", () => {
function Orphan(): null {
useToasts();
return null;
}
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(() => render(<Orphan />)).toThrow("useToasts must be used inside a ToastProvider.");
consoleError.mockRestore();
});
});
describe("ErrorState", () => {
it("leads with a plain message, and shows no disclosure when there is no detail", () => {
render(<ErrorState description="Check the connection, then retry." title="Could not load" />);
const element = container.querySelector(".ek-message--error") as HTMLElement;
expect(element.getAttribute("role")).toBe("alert");
expect(element.querySelector(".ek-message__title")?.textContent).toBe("Could not load");
expect(element.querySelector("details")).toBeNull();
});
it("keeps technical detail and a reference collapsed behind a disclosure", () => {
render(
<ErrorState
details={"STORAGE_PROVIDER_UNAVAILABLE\nThe object store did not answer within 5 s."}
reference="req-7f3a"
title="The image could not be uploaded"
/>,
);
const details = container.querySelector("details") as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(details.querySelector("summary")?.textContent).toBe("Technical details");
expect(details.querySelector("pre")?.textContent).toBe(
"STORAGE_PROVIDER_UNAVAILABLE\nThe object store did not answer within 5 s.",
);
expect(details.querySelector("code")?.textContent).toBe("req-7f3a");
});
it("offers recovery and remedy actions beside the message", () => {
const retry = vi.fn();
render(
<ErrorState
actions={
<>
<button onClick={retry} type="button">
Retry
</button>
<a href="/settings">Open storage settings</a>
</>
}
title="Could not load"
/>,
);
act(() => buttonNamed("Retry").click());
expect(retry).toHaveBeenCalledTimes(1);
expect(container.querySelector(".ek-message__actions a")?.getAttribute("href")).toBe(
"/settings",
);
});
it("renders an arbitrary server string as inert, bounded text", () => {
const hostile = `<img src=x onerror="alert(1)"><script>steal()</script>\u0007\u001b[31m${"x".repeat(5000)}`;
render(<ErrorState details={hostile} title="Something went wrong" />);
const pre = container.querySelector("pre") as HTMLElement;
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("script")).toBeNull();
expect(pre.textContent?.startsWith('<img src=x onerror="alert(1)">')).toBe(true);
expect(pre.textContent).not.toContain("\u0007");
expect(pre.textContent?.length).toBe(ERROR_DETAILS_MAX_LENGTH + 1);
expect(errorDetailsText(" line one\n\tline two ")).toBe("line one\n\tline two");
});
it("can be part of a page's initial content without being announced", () => {
render(<ErrorState announce={false} title="Not available" />);
expect(container.querySelector(".ek-message--error")?.getAttribute("role")).toBeNull();
});
});
describe("Skeleton", () => {
it("is a busy, labelled region announced once, with every placeholder hidden from it", () => {
render(
<SkeletonGroup label="Loading displays">
<Skeleton shape="circle" width="2rem" />
<Skeleton width="60%" />
<Skeleton shape="block" />
</SkeletonGroup>,
);
const group = container.querySelector(".ek-skeleton-group") as HTMLElement;
expect(group.getAttribute("role")).toBe("status");
expect(group.getAttribute("aria-busy")).toBe("true");
expect(container.querySelector(`#${group.getAttribute("aria-labelledby")}`)?.textContent).toBe(
"Loading displays",
);
const placeholders = [...group.querySelectorAll(".ek-skeleton")] as HTMLElement[];
expect(placeholders).toHaveLength(3);
for (const placeholder of placeholders) {
expect(placeholder.getAttribute("aria-hidden")).toBe("true");
expect(placeholder.textContent).toBe("");
}
expect(placeholders[0]?.style.height).toBe("2rem");
// No percentage, no progress: there is none to report.
expect(group.textContent).toBe("Loading displays");
});
it("stops moving for a person who asked for reduced motion", () => {
const reduced = EKKLESIA_UI_STYLESHEET.slice(
EKKLESIA_UI_STYLESHEET.indexOf("@media (prefers-reduced-motion: reduce)"),
);
expect(reduced).toMatch(/\.ek-skeleton[,\s]/u);
expect(reduced).toContain("animation: none");
});
});
describe("PageHeader", () => {
it("names the page with its one h1 and keeps page actions beside it", () => {
render(<PageHeader actions={<button type="button">Add display</button>} title="Displays" />);
expect(container.querySelector("h1")?.textContent).toBe("Displays");
expect(container.querySelector(".ek-page-header__actions button")?.textContent).toBe(
"Add display",
);
expect(container.querySelector(".ek-page-header__back")).toBeNull();
});
it("puts a Back link above the title when Back is a real place", () => {
const onBack = vi.fn();
render(
<PageHeader
back={{ href: "/signage", label: "Back to Signage", onBack }}
title="Add display"
/>,
);
const back = container.querySelector(".ek-page-header__back") as HTMLAnchorElement;
expect(back.tagName).toBe("A");
expect(back.getAttribute("href")).toBe("/signage");
expect(back.textContent).toBe("←Back to Signage");
expect(back.querySelector('[aria-hidden="true"]')?.textContent).toBe("←");
const copy = back.parentElement as HTMLElement;
expect([...copy.children].indexOf(back)).toBeLessThan(
[...copy.children].indexOf(copy.querySelector("h1") as Element),
);
const click = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true });
act(() => {
back.dispatchEvent(click);
});
expect(click.defaultPrevented).toBe(true);
expect(onBack).toHaveBeenCalledTimes(1);
const newTab = new MouseEvent("click", {
bubbles: true,
button: 0,
cancelable: true,
ctrlKey: true,
});
act(() => {
back.dispatchEvent(newTab);
});
expect(newTab.defaultPrevented).toBe(false);
expect(onBack).toHaveBeenCalledTimes(1);
});
it("renders Back as a button when it has no address", () => {
const onBack = vi.fn();
render(<PageHeader back={{ label: "Back", onBack }} title="Details" />);
const back = container.querySelector(".ek-page-header__back") as HTMLButtonElement;
expect(back.tagName).toBe("BUTTON");
expect(back.type).toBe("button");
act(() => back.click());
expect(onBack).toHaveBeenCalledTimes(1);
});
});
+12
View File
@@ -407,6 +407,9 @@ importers:
'@ekklesia/ui':
specifier: workspace:*
version: link:../../packages/ui
lucide-react:
specifier: 1.48.0
version: 1.48.0([email protected])
react:
specifier: 19.2.8
version: 19.2.8
@@ -2401,6 +2404,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
[email protected]:
resolution: {integrity: sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
[email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -4369,6 +4377,10 @@ snapshots:
dependencies:
yallist: 3.1.1
[email protected]([email protected]):
dependencies:
react: 19.2.8
[email protected]:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+67 -1
View File
@@ -6,7 +6,9 @@
# - its module storage mount point, /var/lib/ekklesia/modules, exists, is root-owned, and is empty;
# - EKKLESIA_MODULES_DIR names that directory, in the image configuration and in a running process;
# - a checkout with a populated modules/ directory builds the same image: the runtime file system
# of both builds is identical, and no module from that directory enters it.
# of both builds is identical, and no module from that directory enters it;
# - the hardened verifier keeps /tmp noexec while a dedicated bounded execution scratch loads and
# runs the real sharp 0.35.5/libvips 1.3.4 native proof package.
#
# bash scripts/check-image.sh builds both images from this checkout
# EKKLESIA_CHECK_IMAGE=<image> bash scripts/check-image.sh checks one existing image's contents
@@ -103,6 +105,7 @@ context_copy() {
tag_base="ekklesia-ci:${GITHUB_SHA:-local}-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-"
generic_image="${tag_base}generic-$$"
populated_image="${tag_base}populated-$$"
builder_image="${tag_base}native-proof-builder-$$"
# build_image <context> <tag>
build_image() {
@@ -143,4 +146,67 @@ else
echo "note the two builds' layers differ (a cold build cache); their file systems do not"
fi
step "Proving native package verification with /tmp noexec and a dedicated execution scratch"
built_images+=("$builder_image")
DOCKER_BUILDKIT=1 docker build --quiet --file "$work/generic/deploy/Dockerfile" \
--target build --label "${EKKLESIA_CI_LABEL}=${project}" \
--label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
--tag "$builder_image" "$work/generic" >/dev/null || fail "the native proof builder did not build."
mkdir "$work/native-package"
package_archive="$({
docker run --rm --network none --user "$(id -u):$(id -g)" \
-v "$work/native-package:/proof" --entrypoint node "$builder_image" \
packages/module-pack/tests/support/pack-native-verifier-proof.mjs /proof
} | tail -1)" || fail "the real native proof package did not build."
[[ "$package_archive" == native-proof-0.1.0-linux-x64-glibc.tar.gz ]] ||
fail "the native proof builder returned an unexpected archive."
docker run --rm --network none --read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
--cap-drop ALL --security-opt no-new-privileges \
-e "PROOF_ARCHIVE=$package_archive" -v "$work/native-package:/package:ro" \
--entrypoint sh "$generic_image" -ec '
fail() { echo "check-image: $*" >&2; exit 1; }
verifier() {
EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
node core/dist/cli/verify-module-package.js "/package/$PROOF_ARCHIVE" \
--sha256-file "/package/$PROOF_ARCHIVE.sha256" "$@"
}
scratch_failure() {
if output="$(EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR="$1" \
node core/dist/cli/verify-module-package.js "/package/$PROOF_ARCHIVE" \
--sha256-file "/package/$PROOF_ARCHIVE.sha256" 2>&1)"; then
fail "the verifier accepted unusable execution scratch $1."
fi
printf "%s\n" "$output" | grep -Fq "[MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE]" ||
fail "the verifier did not identify unusable execution scratch $1."
}
grep -Eq " /tmp .*noexec" /proc/mounts || fail "/tmp is not mounted noexec."
verifier --extract-to /tmp/noexec-installed >/tmp/noexec-verify.log
if env -i /usr/local/bin/node --input-type=module --eval \
"await import(\"file:///tmp/noexec-installed/server/index.js\");" \
>/tmp/noexec-load.log 2>&1; then
fail "the native addon unexpectedly loaded from noexec /tmp."
fi
rm -rf /tmp/noexec-installed
scratch_failure /tmp
scratch_failure /missing-verifier-scratch
scratch_failure /package
verifier --extract-to /run/ekklesia-verifier/installed >/tmp/native-verify.log
env -i /usr/local/bin/node --input-type=module --eval \
"const m=await import(\"file:///run/ekklesia-verifier/installed/server/index.js\"); const actual=await m.proof(); const expected={pixels:[255,0,0,255,0,0,255,0,0,255,0,0],png:{format:\"png\",height:3,width:5},resized:{channels:3,height:2,width:2}}; if(JSON.stringify(actual)!==JSON.stringify(expected)) throw new Error(\"sharp proof mismatch\");"
rm -rf /run/ekklesia-verifier/installed
verifier >/tmp/native-verify-a.log 2>&1 & first=$!
verifier >/tmp/native-verify-b.log 2>&1 & second=$!
wait "$first" || { cat /tmp/native-verify-a.log >&2; fail "first concurrent verification failed."; }
wait "$second" || { cat /tmp/native-verify-b.log >&2; fail "second concurrent verification failed."; }
[ -z "$(find /run/ekklesia-verifier -mindepth 1 -print -quit)" ] ||
fail "the verifier left execution scratch behind."
' || fail "the hardened native verifier proof failed."
echo "ok /tmp stayed noexec; native verification, sharp execution, rejection, concurrency, and cleanup passed"
echo "Generic platform image check passed."
+14 -2
View File
@@ -5,6 +5,11 @@
# bash scripts/ci-sweep.sh --stale [--max-age-seconds N] any CI stack older than N (default 3 h)
# bash scripts/ci-sweep.sh --job this job's own stacks, at any age
#
# `--stale --prefix P` narrows the sweep to stacks whose names begin with P, which must itself begin
# with the CI prefix: it can only ever narrow a sweep, never widen one. scripts/test-ci-sweep.sh
# confines its short thresholds with it, because the daemon is shared and another job's live stack
# may be on it right now.
#
# Every CI script already removes its own stack in an EXIT trap, but a trap never runs when the runner
# kills the job container: a cancelled or superseded job (the server honours `concurrency`), or
# the runner's own time limit. So each workflow job that starts stacks ends with `--job` in an
@@ -31,9 +36,10 @@ source "$repository_root/scripts/lib/ci-project.sh"
compose_label="com.docker.compose.project"
mode=""
max_age_seconds="${EKKLESIA_CI_SWEEP_MAX_AGE_SECONDS:-10800}"
narrow_prefix=""
usage() {
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] | --job" >&2
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] [--prefix P] | --job" >&2
exit 2
}
@@ -48,11 +54,17 @@ while (($# > 0)); do
max_age_seconds="$2"
shift
;;
--prefix)
[[ "${2:-}" == "$EKKLESIA_CI_PREFIX"?* ]] || usage
narrow_prefix="$2"
shift
;;
*) usage ;;
esac
shift
done
[[ -n "$mode" ]] || usage
[[ -z "$narrow_prefix" || "$mode" == stale ]] || usage
if [[ "$mode" == job ]]; then
[[ -n "${GITHUB_RUN_ID:-}" ]] || {
@@ -62,7 +74,7 @@ if [[ "$mode" == job ]]; then
prefix="$(ci_job_prefix)"
cutoff=""
else
prefix="$EKKLESIA_CI_PREFIX"
prefix="${narrow_prefix:-$EKKLESIA_CI_PREFIX}"
cutoff="$(($(date +%s) - max_age_seconds))"
fi
+86 -3
View File
@@ -187,10 +187,14 @@ check(
);
// --- A module UI, imported from the API's origin -------------------------------------------------
const moduleButton = "document.querySelector('[data-testid^=\"select-\"]')";
// The first module link — never the Overview, which is the Dashboard's own landing page (ADR 0057).
const moduleButton =
'document.querySelector(\'[data-testid^="select-"]:not([data-testid="select-overview"])\')';
const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
let openedModuleId;
if (hasModule) {
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
openedModuleId = moduleId;
await evaluate(`${moduleButton}.click()`);
const mounted = await waitFor(
`${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
@@ -224,6 +228,20 @@ if (hasModule) {
streams.every((response) => response.status === 200),
`every realtime stream it opened was accepted (${streams.length} opened)`,
);
// A refresh stays on the module's own page: the proxy forwards the deep link, the API serves the
// Dashboard page there, and the shell mounts the same module again (ADR 0057).
const moduleUrl = await evaluate("location.href");
check(
moduleUrl === `${dashboardOrigin}/${moduleId}`,
"opening the module put its own address in the address bar",
);
await navigate(moduleUrl);
const remounted = await waitFor(
`location.href === ${JSON.stringify(moduleUrl)} && ${selector(`select-${moduleId}`)}?.getAttribute("aria-current") === "page" && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
"the module UI to mount again after a refresh",
);
check(remounted, `refreshing ${moduleUrl} reopens the ${moduleId} module`);
}
// A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a
// module unmounting, a navigation) is not.
@@ -266,9 +284,74 @@ if (displayPath === null) {
);
}
// --- Sign out on the Dashboard -------------------------------------------------------------------
// --- The phone layout: the same navigation, in a drawer (ADR 0058) ------------------------------
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 2,
height: 844,
mobile: true,
width: 390,
});
await navigate(`${dashboardOrigin}/`);
await waitFor(selector("host-logout"), "the sign-out control");
check(
await waitFor(
`${selector("mobile-navigation-trigger")} && !${selector("host-sidebar")}`,
"the phone navigation trigger",
),
"a phone-sized window shows the navigation trigger instead of the sidebar",
);
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
check(
await waitFor(
`document.activeElement === ${selector("navigation-drawer-close")} && ${selector("host-main-region")}.inert`,
"focus inside the opened drawer",
),
"the drawer opens as a modal, with focus inside it and the page inert",
);
await evaluate(
`document.activeElement.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }))`,
);
check(
await waitFor(
`!${selector("navigation-drawer")} && document.activeElement === ${selector("mobile-navigation-trigger")}`,
"the drawer to close on Escape",
),
"Escape closes the drawer and returns focus to its trigger",
);
if (openedModuleId) {
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
await waitFor(selector(`select-${openedModuleId}`), "the module in the drawer");
await evaluate(`${selector(`select-${openedModuleId}`)}.click()`);
check(
await waitFor(
`location.pathname === ${JSON.stringify(`/${openedModuleId}`)} && !${selector("navigation-drawer")} && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-error")}`,
"the module chosen from the drawer to open",
),
`choosing ${openedModuleId} in the drawer closes it and opens the module at its own address`,
);
}
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 1,
height: 1080,
mobile: false,
width: 1920,
});
check(
await waitFor(
`${selector("host-sidebar")} && !${selector("mobile-navigation-trigger")}${
openedModuleId
? ` && ${selector(`select-${openedModuleId}`)}?.getAttribute("aria-current") === "page"`
: ""
}`,
"the sidebar after widening the window",
),
"widening the window brings the sidebar back on the same page",
);
// --- Sign out on the Dashboard, from the account menu at the bottom of the sidebar ---------------
await navigate(`${dashboardOrigin}/`);
await waitFor(selector("host-account-menu"), "the account control");
await evaluate(`${selector("host-account-menu")}.click()`);
await waitFor(selector("host-logout"), "the sign-out item in the account menu");
await evaluate(`${selector("host-logout")}.click()`);
check(
await waitFor(selector("login-email"), "the sign-in form after sign-out"),
+76 -14
View File
@@ -35,6 +35,10 @@ function required(name) {
return value;
}
function optional(name) {
return process.env[name] || "";
}
let failures = 0;
function check(condition, description) {
@@ -84,6 +88,27 @@ async function edgeChecks() {
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
);
// Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). No module
// is installed yet at this point; a module's own address is reloaded in a real browser later.
for (const deepLink of ["/settings", "/settings/displays/add", "/account"]) {
const deepPage = await fetch(`${dashboardOrigin}${deepLink}`);
check(
deepPage.status === 200 &&
deepPage.headers.get("etag") === page.headers.get("etag") &&
(await deepPage.text()) === html,
`GET ${deepLink} through the proxy is the same Dashboard page`,
);
}
// A top-level name shaped like a module id reaches the API, which knows the installed modules and
// answers anything that is not a Dashboard page with its sanitized 404 — never the page.
for (const unknown of ["/definitely-not-a-real-module", "/settings-old", "/modules/example"]) {
const response = await fetch(`${dashboardOrigin}${unknown}`);
check(
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
`GET ${unknown} through the proxy is the API's sanitized 404, not a Dashboard page`,
);
}
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
await script.arrayBuffer();
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
@@ -148,6 +173,10 @@ async function splitRoutingChecks() {
"the Dashboard hostname refuses /display/*",
);
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /");
check(
await refusedByProxy(`${apiOrigin}/settings/displays`),
"the API hostname serves no Dashboard page either",
);
check(
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
"the API hostname refuses the Dashboard's script",
@@ -462,36 +491,69 @@ async function ownerCookie() {
}
/**
* The installed-module checks (ADR 0048): whether `example` is in the module inventory, and what
* its own route returns. Absent, it is in no inventory and its routes are not mounted at all;
* present, its records route returns exactly the expected values, which proves its tables, its
* dependencies, and the platform's database facade all work from module storage.
* The installed-module checks (ADR 0048): whether the selected module is in the UI inventory and,
* when one is supplied, whether its module-owned probe route responds. Core's own `example` fixture
* retains its stronger records assertion, which also proves its database facade.
*/
async function moduleChecks() {
const expectation = required("SMOKE_MODULE_EXPECT");
const moduleId = optional("SMOKE_MODULE_ID") || "example";
const probePath = optional("SMOKE_MODULE_PROBE_PATH");
const cookie = await ownerCookie();
const headers = { cookie, origin: dashboardOrigin };
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
const inventoryBody = await inventory.json();
const installed =
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === "example");
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === moduleId);
if (expectation === "absent") {
check(inventory.status === 200 && !installed, "the module inventory has no `example` module");
check(
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
"the absent module's routes are not mounted",
inventory.status === 200 && !installed,
`the module inventory has no \`${moduleId}\` module`,
);
} else {
const expected = JSON.parse(expectation);
check(installed, "the module inventory lists the `example` module");
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
check(installed, `the module inventory lists the \`${moduleId}\` module`);
}
// The module's own Dashboard address (ADR 0057) exists exactly while the module is installed with a
// Dashboard UI — disabled or not: the Dashboard page when it is, the API's sanitized 404 when it is
// not. A disabled module is not running, but its address still opens the Dashboard.
const pageExpectation =
optional("SMOKE_MODULE_PAGE") || (expectation === "absent" ? "absent" : "served");
const modulePage = await fetch(`${dashboardOrigin}/${moduleId}`);
const modulePageBody = await modulePage.text();
if (pageExpectation === "absent") {
check(
JSON.stringify(values) === JSON.stringify(expected),
`the module's records route returns ${JSON.stringify(expected)}`,
modulePage.status === 404 && modulePageBody === '{"code":"NOT_FOUND"}',
`GET /${moduleId} is the sanitized 404 while the module is not installed`,
);
} else {
check(
modulePage.status === 200 && modulePageBody.includes('id="ekklesia-host"'),
`GET /${moduleId} through the proxy is the Dashboard page while the module is installed`,
);
}
if (moduleId === "example") {
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
if (expectation === "absent") {
check(
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
"the absent module's routes are not mounted",
);
} else {
const expected = JSON.parse(expectation);
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
check(
JSON.stringify(values) === JSON.stringify(expected),
`the module's records route returns ${JSON.stringify(expected)}`,
);
}
} else if (expectation !== "absent" && probePath) {
const probe = await fetch(`${apiOrigin}${probePath}`, { headers });
await probe.arrayBuffer();
check(probe.status === 200, `the module probe ${probePath} responds`);
}
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
+69 -18
View File
@@ -14,7 +14,7 @@
// stream-failure the first realtime connection is refused: is realtime re-established?
// caching-probe can a display on the API origin store storage-origin images in Cache Storage?
// stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline
// revoke revoke the paired kiosk while its display is open
// revoke revoke the paired kiosk while its display is open, then pair it again
//
// Every DevTools command and every page-side request has its own deadline, and a step has an overall
// budget. A call that never answers fails the step with a line naming that exact call, because a call
@@ -426,23 +426,48 @@ async function seedSignage(owner) {
const base = "/api/v1/modules/signage";
const images = [];
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
const uploaded = await owner.evaluate(
`(async () => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 360;
const context = canvas.getContext("2d");
context.fillStyle = ${JSON.stringify(colour)};
context.fillRect(0, 0, 640, 360);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
const response = await fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
signal: AbortSignal.timeout(${requestTimeoutMs}),
});
return { status: response.status, body: await response.json().catch(() => null) };
})()`,
`uploading slide ${index}`,
);
let uploaded;
for (let attempt = 1; attempt <= 2; attempt += 1) {
uploaded = await owner.evaluate(
`(async () => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 360;
const context = canvas.getContext("2d");
context.fillStyle = ${JSON.stringify(colour)};
context.fillRect(0, 0, 640, 360);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
return fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
signal: AbortSignal.timeout(${requestTimeoutMs}),
}).then(
async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
);
})()`,
`uploading slide ${index}`,
);
if (
attempt === 1 &&
uploaded.status === 503 &&
uploaded.body?.code === "SIGNAGE_STORAGE_UNAVAILABLE"
) {
note(`slide ${index} met one transient storage outage; retrying once`);
await sleep(1_000);
continue;
}
break;
}
if (uploaded.status === "timeout") {
throw new HangError(`HTTP POST ${base}/images from page "owner"`, requestTimeoutMs);
}
if (uploaded.status !== 200 && uploaded.status !== 201) {
const code =
typeof uploaded.body?.code === "string" && /^SIGNAGE_[A-Z_]+$/u.test(uploaded.body.code)
? ` (${uploaded.body.code})`
: "";
throw new Error(`Uploading slide ${index} returned HTTP ${uploaded.status}${code}.`);
}
images.push(uploaded.body?.image?.id);
}
check(
@@ -951,6 +976,32 @@ async function run() {
result.stopped,
`a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`,
);
// Revocation is recoverable: the same screen pairs again and resumes, keeping its place in the
// module's roster. It also leaves the kiosk paired for the steps that run after this one.
const rosterBefore = moduleId === "signage" ? (await signageViewers(owner.page)).length : 0;
if (await page.waitFor(find("display-pairing-code"), "a fresh pairing code after revocation")) {
const code = (
await page.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code")
).replace(/\D/gu, "");
const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", {
code,
deviceName: "Repro kiosk",
});
check(approved.status === 200, "the owner approves the revoked screen's new code");
check(
await waitLive(page, "the re-paired display to go live"),
"a revoked screen is paired again and resumes without a reload",
);
if (moduleId === "signage") {
const rosterAfter = (await signageViewers(owner.page)).length;
check(
rosterAfter === rosterBefore,
`re-pairing keeps the screen's existing roster entry (${rosterBefore} before, ${rosterAfter} after)`,
);
}
} else {
check(false, "a revoked screen shows a fresh pairing code");
}
await page.close();
kiosk.close();
await finishOwner(owner);
+200 -52
View File
@@ -7,6 +7,8 @@
# bash scripts/smoke-deploy.sh builds the image first
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
# EKKLESIA_SMOKE_MODULE_RELEASE_DIR=<dir> EKKLESIA_SMOKE_MODULE_ID=<id> \
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
#
# Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile.
# It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the
@@ -15,10 +17,10 @@
# ADR 0048 end to end with that same image:
#
# - it boots with no modules;
# - the repository's module template is packaged by the canonical packager as an Ekklesia Module
# Package v1 (ADR 0049; scripts/lib/build-module-package.Dockerfile only provides the build
# environment), verified by the same image's own verifier, extracted to staging, installed into
# module storage, its migrations planned and explicitly applied, and the API restarted;
# - either the repository's module template is packaged by the canonical packager, or an external
# canonical release pair is supplied through EKKLESIA_SMOKE_MODULE_RELEASE_DIR; the package is
# verified by the same image's own verifier, extracted to staging, installed into module storage,
# its migrations planned and explicitly applied, and the API restarted;
# - a real headless Chromium drives the Dashboard, the module, its display, and sign-out;
# - the module is removed and the API restarted: Core still runs, migration planning reports the
# module's retained history, and its data survives;
@@ -83,6 +85,28 @@ same-origin | split) ;;
;;
esac
module_release_directory="${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"
module_id="${EKKLESIA_SMOKE_MODULE_ID:-example}"
module_probe_path="${EKKLESIA_SMOKE_MODULE_PROBE_PATH:-}"
[[ "$module_id" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]] || {
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_ID is not a valid module id." >&2
exit 1
}
if [[ -n "$module_release_directory" ]]; then
[[ "$topology" == split ]] || {
echo "smoke-deploy: an external module release requires the split topology." >&2
exit 1
}
[[ -d "$module_release_directory" ]] || {
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_RELEASE_DIR is not a directory." >&2
exit 1
}
fi
if [[ -n "$module_probe_path" && "$module_probe_path" != "/api/v1/modules/${module_id}/"* ]]; then
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_PROBE_PATH must be inside the selected module's API namespace." >&2
exit 1
fi
run_id="$(openssl rand -hex 4)"
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries
@@ -217,22 +241,31 @@ if [[ -z "$image" ]]; then
fi
if [[ "$topology" == split ]]; then
# The module template as a Module Package v1, from the canonical packager. Never a second platform
# image: the package is installed into module storage below, after the image verifies it.
step "Packaging the module template with the canonical Ekklesia packager"
source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/build-module-package.Dockerfile \
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
--output "type=local,dest=${work}/module-package" . >/dev/null ||
fail "the module package did not build."
# Never a second platform image: the package is installed into module storage below, after the
# generic image verifies it. The default keeps Core's own template smoke self-contained; downstream
# module CI hands this harness the exact release pair it built instead.
if [[ -z "$module_release_directory" ]]; then
step "Packaging the module template with the canonical Ekklesia packager"
source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/build-module-package.Dockerfile \
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
--output "type=local,dest=${work}/module-package" . >/dev/null ||
fail "the module package did not build."
package_output="$work/module-package"
else
step "Accepting the external module's canonical release pair"
package_output="$module_release_directory"
fi
# The archive and its digest are one release: copied together, from this run's own build output,
# into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is
# mode 0700 and owned by whoever built it, so it is never handed to the verifier itself.
package_archive="$(module_package_release "$work/module-package" "$work/package")" ||
package_archive="$(module_package_release "$package_output" "$work/package")" ||
fail "the packager did not produce exactly one archive and its digest."
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
fail "the packager produced an unexpected archive name."
if [[ -z "$module_release_directory" ]]; then
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
fail "the packager produced an unexpected archive name."
fi
echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))"
fi
@@ -446,22 +479,48 @@ module_storage() {
install_test_module() {
local verified
verified="$(
docker run --rm --network none --read-only --tmpfs /tmp --cap-drop ALL \
--security-opt no-new-privileges \
docker run --rm --network none --read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
--cap-drop ALL --security-opt no-new-privileges \
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
-v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
--sha256-file "/package/${package_archive}.sha256" --extract-to /staging/example 2>&1
--sha256-file "/package/${package_archive}.sha256" --extract-to "/staging/${module_id}" 2>&1
)" || fail "the module package did not verify: ${verified}"
echo "ok ${verified%%$'\n'*}"
docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
-v "$daemon_work/staging:/staging" "$postgres_image" -ec '
rm -rf /modules/.incoming-example && cp -R /staging/example /modules/.incoming-example &&
rm -rf /staging/example && chown -R 0:0 /modules/.incoming-example &&
chmod -R a+rX,go-w /modules/.incoming-example &&
mv /modules/.incoming-example /modules/example' ||
-v "$daemon_work/staging:/staging" -e MODULE_ID="$module_id" "$postgres_image" -ec '
incoming="/modules/.incoming-${MODULE_ID}"
installed="/modules/${MODULE_ID}"
staged="/staging/${MODULE_ID}"
rm -rf "$incoming" && cp -R "$staged" "$incoming" && rm -rf "$staged" &&
chown -R 0:0 "$incoming" && chmod -R a+rX,go-w "$incoming" && mv "$incoming" "$installed"' ||
fail "the verified module could not be installed into module storage."
}
# The package and its claimed digest are deliberately made to disagree. The verifier must reject it
# before extraction; the valid release pair is left untouched for the installation that follows.
reject_invalid_package() {
local rejected zero_digest
zero_digest="$(printf '0%.0s' {1..64})"
if rejected="$(
docker run --rm --network none --read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
--cap-drop ALL --security-opt no-new-privileges \
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
-v "$daemon_work/package:/package:ro" \
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
--sha256 "$zero_digest" 2>&1
)"; then
fail "the verifier accepted a package whose archive did not match its claimed digest."
fi
grep -q '^\[MODULE_PACKAGE_DIGEST_MISMATCH\]' <<<"$rejected" ||
fail "the invalid package was rejected for an unexpected reason."
echo "ok an invalid package is rejected before installation"
}
# restart_api <command...>: stops the API, runs the command, and starts the API again. A controlled
# restart is the only way a module change is picked up; nothing is ever hot loaded.
restart_api() {
@@ -473,9 +532,25 @@ restart_api() {
fi
}
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
# recreate_api removes the application container, not its installation-owned module bind mount or
# database volumes, then creates a fresh container from the same generic image.
recreate_api() {
"${compose[@]}" stop api >/dev/null 2>&1 || fail "the API did not stop for recreation."
"${compose[@]}" rm --force api >/dev/null 2>&1 || fail "the API container was not removed."
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 api 2>&1)"; then
compose_report_not_ready "$up_output" "${compose[@]}"
fail "the recreated API did not become healthy."
fi
}
# module_check <expectation> [page]: "absent", or the exact JSON array of values `example` must
# return. `page` is whether its Dashboard address is served (ADR 0057): by default, exactly when the
# module is running; "served" for a module that is installed but disabled, whose address still opens
# the Dashboard so it can say the module is unavailable.
module_check() {
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
-e SMOKE_MODULE_PAGE="${2:-}" \
-e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
}
@@ -488,21 +563,36 @@ if [[ "$topology" == split ]]; then
step "Booting the generic image with empty module storage"
module_check absent || fail "the image without modules did not run module-free."
step "Rejecting an invalid package before installation"
reject_invalid_package
step "Installing the test module into module storage, migrating explicitly, and restarting the API"
install_module_and_migrate() {
install_test_module
local plan
plan="$(plan_output)"
grep -q '^- \[pending\] example:0000_initial ' <<<"$plan" ||
grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan" ||
fail "the plan does not list the installed module's migration as pending."
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "applying the installed module's migration failed."
plan="$(plan_output)"
grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
fail "the plan does not list the installed module's migration as applied."
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
fail "the installed module still has a pending migration after apply."
fi
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "reapplying migrations was not idempotent."
}
restart_api install_module_and_migrate
module_check '[]' || fail "the installed module did not run from module storage."
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
>/dev/null || fail "could not write the module's own data."
module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
if [[ "$module_id" == example ]]; then
module_check '[]' || fail "the installed module did not run from module storage."
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
>/dev/null || fail "could not write the module's own data."
module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
else
module_check present || fail "the installed module did not run from module storage."
fi
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
@@ -513,24 +603,31 @@ if [[ "$topology" == split ]]; then
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
browser_container="${project}-browser"
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
"$browser_image" \
"--ignore-certificate-errors-spki-list=${spki}" \
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
start_owner_browser() {
docker rm --force "$browser_container" >/dev/null 2>&1 || true
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
"$browser_image" \
"--ignore-certificate-errors-spki-list=${spki}" \
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
}
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
stage browser.mjs
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
--label "${EKKLESIA_CI_LABEL}=${project}" \
--network "$edge_network" \
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed."
run_browser_checks() {
start_owner_browser
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
--label "${EKKLESIA_CI_LABEL}=${project}" \
--network "$edge_network" \
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
--entrypoint node "$image" /smoke/browser.mjs
}
run_browser_checks || fail "browser checks failed."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
# A kiosk: a second browser whose profile directory is a volume, so it outlives the container
@@ -566,6 +663,7 @@ if [[ "$topology" == split ]]; then
--network "$edge_network" \
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
-e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \
-e SMOKE_CALL_TIMEOUT_MS="${EKKLESIA_SMOKE_CALL_TIMEOUT_MS:-30000}" \
-e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
-e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \
@@ -607,29 +705,79 @@ if [[ "$topology" == split ]]; then
((display_status == 0)) || fail "display-device checks failed."
fi
step "Restarting the API process with the installed module and data intact"
restart_api
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the module or its data did not survive an API restart."
else
module_check present || fail "the module did not survive an API restart."
fi
run_browser_checks || fail "the module UI did not survive an API restart."
step "Recreating the API container with the same module storage and database volumes"
recreate_api
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the module or its data did not survive container recreation."
else
module_check present || fail "the module did not survive container recreation."
fi
plan="$(plan_output)"
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
fail "container recreation made an applied module migration pending again."
fi
run_browser_checks || fail "the module UI did not survive container recreation."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
display_driver 120 restart || fail "the display runtime or its data did not survive container recreation."
fi
step "Disabling and re-enabling the module through the supported lifecycle command"
restart_api "${compose[@]}" run --rm --no-deps migrate node \
core/dist/cli/set-module-enabled.js --disable --module "$module_id"
module_check absent served || fail "the disabled module was still reported as running."
module_storage "test -d /modules/${module_id}" ||
fail "disabling the module removed its installed package."
restart_api "${compose[@]}" run --rm --no-deps migrate node \
core/dist/cli/set-module-enabled.js --enable --module "$module_id"
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the re-enabled module did not return with its data."
else
module_check present || fail "the re-enabled module did not return."
fi
run_browser_checks || fail "the re-enabled module UI did not return."
step "Removing the module from module storage and restarting: Core runs, its data stays"
restart_api module_storage 'rm -rf /modules/example'
restart_api module_storage "rm -rf /modules/${module_id}"
module_check absent || fail "the removed module is still running."
plan="$(plan_output)"
grep -q '^- \[retained\] example: 1 applied migration kept; the module is not installed$' \
grep -q "^- \\[retained\\] ${module_id}: .*applied migration.*kept; the module is not installed$" \
<<<"$plan" || fail "migration planning did not report the removed module's retained history."
grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" ||
fail "migration planning did not succeed cleanly with the module absent."
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "applying migrations failed with the module absent."
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
fail "the removed module's data did not survive."
if [[ "$module_id" == example ]]; then
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
fail "the removed module's data did not survive."
fi
echo "ok the module is gone, Core runs, its history is retained, and its data survives"
step "Reinstalling the exact same package and restarting: the module returns with its previous data"
restart_api install_test_module
plan="$(plan_output)"
grep -q '^- \[applied\] example:0000_initial ' <<<"$plan" ||
grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
fail "the restored module's migration history is not valid."
if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then
fail "the restored module left retained or pending migrations."
fi
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
else
module_check present || fail "the restored module did not return."
fi
run_browser_checks || fail "the restored module UI did not return."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
display_driver 120 restart || fail "the restored display runtime did not return with its data."
fi
echo "ok the restored module runs again, with its history strict and its data intact"
fi
+18 -5
View File
@@ -4,8 +4,10 @@
#
# bash scripts/test-ci-sweep.sh
#
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, so
# it can never touch a real stack; and it removes all of them on exit, whatever happens.
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, and
# every sweep it runs is confined to those identifiers, so it can never touch a real stack: the
# runner's daemon is shared, and another job's live stack may be on it right now. It removes all of
# its own resources on exit, whatever happens.
set -Eeuo pipefail
@@ -91,10 +93,14 @@ source "$repository_root/scripts/lib/ci-project.sh"
# Fake run identifiers: nothing here can match a real run's stacks.
export GITHUB_RUN_ID="sweeptest${nonce}"
export GITHUB_RUN_ATTEMPT="1"
# Every stack this test sweeps begins with this; nothing it sweeps reaches past it.
own_run="$(ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID}-")"
mine="$(EKKLESIA_CI_JOB_KEY=alpha ci_project_name mine)"
sibling="$(EKKLESIA_CI_JOB_KEY=beta ci_project_name sibling)"
foreign="ekklesia-sweep-control-${nonce}"
for project in "$mine" "$sibling" "$foreign"; do
# Stands in for another run's live stack on the shared daemon — a module's integration job, say.
bystander="$(GITHUB_RUN_ID="bystander${nonce}" EKKLESIA_CI_JOB_KEY=gamma ci_project_name live)"
for project in "$mine" "$sibling" "$foreign" "$bystander"; do
make_stack "$project"
done
# A job container connected to this job's stack network, exactly as compose-job-network.sh does.
@@ -115,12 +121,19 @@ check '! env -u GITHUB_RUN_ID bash scripts/ci-sweep.sh --job >/dev/null 2>&1' \
"--job without GITHUB_RUN_ID fails"
echo "--- --stale removes only CI stacks older than its threshold"
bash scripts/ci-sweep.sh --stale
bash scripts/ci-sweep.sh --stale --prefix "$own_run"
check 'project_exists "$sibling"' "a young CI stack survives the default threshold"
bash scripts/ci-sweep.sh --stale --max-age-seconds 0
bash scripts/ci-sweep.sh --stale --max-age-seconds 0 --prefix "$own_run"
check '! project_exists "$sibling"' "a CI stack older than the threshold is removed"
check '! staging_exists "$sibling"' "its staging directory too"
check 'project_exists "$foreign"' "a stack outside the CI prefix is never removed, at any age"
check 'project_exists "$bystander"' \
"another run's stack is never removed by a narrowed sweep, even past the threshold"
echo "--- --prefix only ever narrows the sweep"
check '! bash scripts/ci-sweep.sh --stale --prefix "ekklesia-" >/dev/null 2>&1' \
"a prefix outside the CI prefix is refused"
check '! bash scripts/ci-sweep.sh --job --prefix "$own_run" >/dev/null 2>&1' \
"--prefix with --job is refused"
if ((failures > 0)); then
echo "test-ci-sweep: ${failures} check(s) failed." >&2
+1 -1
View File
@@ -110,7 +110,7 @@ for user in publisher outsider; do
password_variable="${user}_password"
api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null
done
owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
owners=(Zayin ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
for owner in "${owners[@]}"; do
api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null
team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' |
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/shell",
"version": "0.2.2",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
@@ -16,6 +16,10 @@
"types": "./dist/dashboard-assets.d.ts",
"import": "./dist/dashboard-assets.js"
},
"./dashboard-routes": {
"types": "./dist/dashboard-routes.d.ts",
"import": "./dist/dashboard-routes.js"
},
"./display-assets": {
"types": "./dist/display-assets.d.ts",
"import": "./dist/display-assets.js"
@@ -33,6 +37,7 @@
"@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/ui": "workspace:*",
"lucide-react": "1.48.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
+7 -1
View File
@@ -89,7 +89,13 @@ await copyFile(
if (serve) {
const context = await esbuild.context(buildOptions);
await context.watch();
const { host, port } = await context.serve({ servedir: outputDirectory });
// Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as
// `/example` reaches the shell's router here as it does from `apps/api` (ADR 0057).
// Development only: the API serves the page only at its platform pages and installed modules'.
const { host, port } = await context.serve({
fallback: path.join(outputDirectory, "index.html"),
servedir: outputDirectory,
});
console.log(`Ekklesia shell (development) on http://${host}:${port}`);
console.log(
apiBaseUrl === ""
+187
View File
@@ -0,0 +1,187 @@
import {
type KeyboardEvent,
type ReactElement,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { useNavTooltip } from "./NavTooltip.js";
import { controlHeight, type NavigationPresentation } from "./ShellNavigation.js";
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
export interface AccountMenuProps {
readonly presentation: NavigationPresentation;
readonly onSignOut: () => void;
}
interface AccountAction {
readonly id: string;
readonly label: string;
readonly icon: ShellIconName;
readonly testId: string;
readonly run: () => void;
}
/**
* The signed-in person's account control, at the bottom of the navigation (DESIGN.md §4.5).
*
* A product-owned menu button — never browser UI — following the WAI-ARIA menu button pattern: the
* trigger says it opens a menu and whether it is open; opening moves focus to the first item (or
* the last, from ArrowUp); arrow keys, Home, and End move between items; Escape closes it and
* returns focus to the trigger; Tab or a pointer press outside closes it and leaves focus where it
* went.
*
* It offers **only actions that work today** — Sign out. Profile, personal preferences, and
* appearance belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages; until then there
* is no dead link to `/account`. The shell knows the person only by an opaque user id
* (`/api/v1/principal`), which is never shown, so the control names the area rather than the person.
*/
export function AccountMenu(props: AccountMenuProps): ReactElement {
const [open, setOpen] = useState(false);
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
const wrapperRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const menuId = useId();
const triggerId = useId();
const { collapsed } = props.presentation;
const { tooltip, triggerProps } = useNavTooltip("Your account", collapsed && !open);
const close = useCallback((returnFocus: boolean) => {
setOpen(false);
if (returnFocus) {
triggerRef.current?.focus();
}
}, []);
const actions: readonly AccountAction[] = [
{
icon: "signOut",
id: "sign-out",
label: "Sign out",
run: () => {
setOpen(false);
props.onSignOut();
},
testId: "host-logout",
},
];
const openMenu = (focus: "first" | "last"): void => {
setInitialFocus(focus);
setOpen(true);
};
useEffect(() => {
if (!open) {
return;
}
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
(initialFocus === "last" ? items.at(-1) : items[0])?.focus();
}, [initialFocus, open]);
// A press anywhere outside the menu and its trigger dismisses it, leaving focus alone.
useEffect(() => {
const owner = wrapperRef.current?.ownerDocument;
if (!open || !owner) {
return;
}
const dismiss = (event: Event): void => {
if (!(event.target instanceof Node) || !wrapperRef.current?.contains(event.target)) {
setOpen(false);
}
};
owner.addEventListener("pointerdown", dismiss);
return () => owner.removeEventListener("pointerdown", dismiss);
}, [open]);
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
const index = items.indexOf(event.target as HTMLButtonElement);
const focusAt = (next: number): void => {
event.preventDefault();
items[(next + items.length) % items.length]?.focus();
};
if (event.key === "Escape") {
// Only the menu closes: a drawer around it stays open.
event.preventDefault();
event.stopPropagation();
close(true);
} else if (event.key === "ArrowDown") {
focusAt(index + 1);
} else if (event.key === "ArrowUp") {
focusAt(index - 1);
} else if (event.key === "Home") {
focusAt(0);
} else if (event.key === "End") {
focusAt(items.length - 1);
} else if (event.key === "Tab") {
setOpen(false);
}
};
return (
<div ref={wrapperRef}>
{open ? (
<div
aria-labelledby={triggerId}
className="ek-shell-menu"
data-testid="host-account-menu-list"
id={menuId}
onKeyDown={onMenuKeyDown}
role="menu"
>
{actions.map((action, index) => (
<button
className="ek-button ek-focusable ek-button--quiet ek-shell-menu__item"
data-testid={action.testId}
key={action.id}
onClick={action.run}
ref={(element) => {
itemRefs.current[index] = element;
}}
role="menuitem"
style={{ minHeight: controlHeight(props.presentation) }}
tabIndex={-1}
type="button"
>
<ShellIcon name={action.icon} />
{action.label}
</button>
))}
</div>
) : undefined}
<button
aria-controls={open ? menuId : undefined}
aria-expanded={open}
aria-haspopup="menu"
className="ek-button ek-focusable ek-button--quiet ek-shell-nav-item"
data-testid="host-account-menu"
id={triggerId}
onClick={() => (open ? close(false) : openMenu("first"))}
ref={triggerRef}
style={{ minHeight: controlHeight(props.presentation), width: "100%" }}
type="button"
{...triggerProps}
onKeyDown={(event) => {
triggerProps.onKeyDown(event);
if (event.key === "ArrowDown") {
event.preventDefault();
openMenu("first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
openMenu("last");
}
}}
>
<ShellIcon name="account" />
<span className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"}>
Your account
</span>
</button>
{tooltip}
</div>
);
}
@@ -0,0 +1,108 @@
import { designToken } from "@ekklesia/design-tokens";
import type {
CSSProperties,
FocusEventHandler,
KeyboardEventHandler,
MouseEvent,
MouseEventHandler,
ReactElement,
ReactNode,
} from "react";
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
import type { HostHistory } from "./host-history.js";
export interface DashboardLinkProps {
readonly history: HostHistory;
readonly to: DashboardDestination;
readonly children: ReactNode;
/** Marks the link as the page currently open, for assistive technology and visually. */
readonly current?: boolean;
readonly variant?: "navigation" | "action";
/** Called after an in-place navigation, including to the page already open — for a drawer that
* closes once a destination is chosen. Not called for a click the browser handles itself. */
readonly onNavigate?: () => void;
readonly className?: string;
readonly style?: CSSProperties;
readonly onBlur?: FocusEventHandler<HTMLAnchorElement>;
readonly onFocus?: FocusEventHandler<HTMLAnchorElement>;
readonly onKeyDown?: KeyboardEventHandler<HTMLAnchorElement>;
readonly onMouseEnter?: MouseEventHandler<HTMLAnchorElement>;
readonly onMouseLeave?: MouseEventHandler<HTMLAnchorElement>;
readonly "data-testid"?: string;
}
const variantClass = {
action: "ek-button ek-focusable ek-button--secondary",
navigation: "ek-button ek-focusable ek-button--quiet",
} as const;
const navigationStyle: CSSProperties = {
justifyContent: "flex-start",
textDecoration: "none",
width: "100%",
};
/** The open page is shown by weight as well as colour, so it never depends on colour alone. */
const currentStyle: CSSProperties = {
...navigationStyle,
background: designToken("state.selected-background"),
color: designToken("state.selected-foreground"),
fontWeight: designToken("typography.weight-bold"),
};
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
return (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
);
}
/**
* A link to a Dashboard page, as a real `<a href>` (ADR 0057).
*
* A real link rather than a button, because it is one: it can be opened in a new tab, copied, and
* bookmarked, and assistive technology announces it as a link. An ordinary click opens the page in
* place through the shell's history instead of reloading the document; every other click — with a
* modifier key, or the middle button — is left to the browser.
*/
export function DashboardLink(props: DashboardLinkProps): ReactElement {
const href = dashboardPath(props.to);
const variant = props.variant ?? "navigation";
const baseStyle =
variant === "action"
? { textDecoration: "none" }
: props.current
? currentStyle
: navigationStyle;
return (
<a
aria-current={props.current ? "page" : undefined}
className={
props.className ? `${variantClass[variant]} ${props.className}` : variantClass[variant]
}
data-testid={props["data-testid"]}
href={href}
onBlur={props.onBlur}
onClick={(event) => {
if (isBrowserHandledClick(event)) {
return;
}
event.preventDefault();
props.history.push(href);
props.onNavigate?.();
}}
onFocus={props.onFocus}
onKeyDown={props.onKeyDown}
onMouseEnter={props.onMouseEnter}
onMouseLeave={props.onMouseLeave}
style={props.style ? { ...baseStyle, ...props.style } : baseStyle}
>
{props.children}
</a>
);
}
+41 -3
View File
@@ -1,5 +1,5 @@
import { designToken } from "@ekklesia/design-tokens";
import { Button, Field, TextInput } from "@ekklesia/ui";
import { Button, Dialog, Field, TextInput } from "@ekklesia/ui";
import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react";
import {
approveDevicePairing,
@@ -61,6 +61,10 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [pairing, setPairing] = useState<PairingStep>({ step: "idle" });
/** The device an administrator asked to revoke and has not yet confirmed. */
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
const [revoking, setRevoking] = useState(false);
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
const refresh = useCallback(async (): Promise<void> => {
try {
@@ -106,13 +110,15 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
};
const revoke = async (deviceId: string): Promise<void> => {
setRevoking(true);
try {
await revokeHostDevice(config, deviceId);
await refresh();
} catch {
// Left to the next refresh rather than asserting a failure the operator cannot act on.
}
await refresh();
setRevoking(false);
setConfirming(undefined);
};
return (
@@ -195,7 +201,7 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
{device.revoked ? undefined : (
<Button
data-testid={`device-revoke-${device.id}`}
onClick={() => void revoke(device.id)}
onClick={() => setConfirming(device)}
size="sm"
>
Revoke
@@ -203,6 +209,38 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
)}
</div>
))}
{/* Revoking strands the screen until someone re-pairs it in person, so the consequence is
spelled out before it happens — never a browser-native confirm. */}
<Dialog
actions={
<>
<Button
data-testid="device-revoke-cancel"
disabled={revoking}
onClick={dismissConfirmation}
>
Cancel
</Button>
<Button
data-testid="device-revoke-confirm"
disabled={revoking}
onClick={() => (confirming ? void revoke(confirming.id) : undefined)}
variant="danger"
>
Revoke display
</Button>
</>
}
onDismiss={dismissConfirmation}
open={confirming !== undefined}
title={`Revoke ${confirming?.name ?? "this display"}?`}
>
<p style={{ margin: 0 }}>
The screen stops showing content within a few seconds and returns to its pairing code.
This cannot be undone: to use it again, pair it from the screen with a new code.
</p>
</Dialog>
</section>
);
}
+125 -158
View File
@@ -1,7 +1,15 @@
import { designToken } from "@ekklesia/design-tokens";
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { Button } from "@ekklesia/ui";
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
import {
type ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import {
HostAuthError,
type HostAuthLanding,
@@ -12,6 +20,7 @@ import {
signInWithEmail,
signOutHost,
} from "../auth-protocol.js";
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
import {
bootstrapHost,
type DashboardNavigationEntry,
@@ -20,11 +29,15 @@ import {
HostProtocolError,
loadHostPrincipal,
} from "../host-protocol.js";
import { DevicePanel } from "./DevicePanel.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import type { HostHistory } from "./host-history.js";
import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js";
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js";
import { ShellLayout } from "./ShellLayout.js";
import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js";
import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
type AuthenticatedPrincipal = Extract<
@@ -101,75 +114,35 @@ export interface HostAppProps {
* so there is nothing an open redirect could use.
*/
readonly authLanding?: HostAuthLanding;
/**
* The address bar (ADR 0057). The current path decides which page is open, so a refresh, a
* bookmark, and Back and Forward all land where the person was. The entry point passes the
* browser's; tests pass an in-memory one. Must be a stable reference.
*/
readonly history: HostHistory;
/** Names the browser tab after the open page. Supplied by the entry point, which owns `document`;
* left unset, the shell names nothing. */
readonly setDocumentTitle?: (title: string) => void;
/**
* Whether the page is laid out for a phone (ADR 0058). The entry point passes the browser's media
* query; left unset, the wide layout. Must be a stable reference.
*/
readonly viewport?: HostViewport;
/**
* Where the desktop sidebar's expanded or collapsed mode is remembered (ADR 0058). The entry point
* passes this browser's storage; left unset, the mode lasts only as long as this component.
*/
readonly sidebarPreference?: SidebarPreference;
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter;
}
/**
* The shell's own chrome, expressed in the platform's global semantic tokens rather than in literal
* colours (ADR 0039). Nothing here is a brand decision any more: changing
* `--ekklesia-background-page` at the host root changes the Dashboard and every mounted module
* artifact together, because custom properties inherit through the artifacts' isolated roots.
* The host's full-page messages (loading, failure, sign-out), in the platform's semantic tokens
* (ADR 0039). The signed-in Dashboard's own frame is `ShellLayout` (ADR 0058).
*/
const shellStyles = {
actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" },
aside: {
background: designToken("background.canvas"),
borderRight: `${designToken("border.width")} solid ${designToken("border.divider")}`,
boxSizing: "border-box",
minWidth: "15rem",
overflowY: "auto",
padding: designToken("spacing.xl"),
width: "15rem",
},
brand: { alignItems: "center", display: "flex", gap: designToken("spacing.sm") },
brandMark: {
background: designToken("accent.base"),
borderRadius: designToken("radius.md"),
boxShadow: `inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")}`,
display: "block",
height: designToken("spacing.xl"),
width: designToken("spacing.xl"),
},
groupLabel: {
color: designToken("text.muted"),
fontSize: designToken("typography.size-xs"),
letterSpacing: "0.05em",
margin: `0 0 ${designToken("spacing.xs")}`,
textTransform: "uppercase",
},
header: {
alignItems: "center",
background: designToken("background.surface"),
borderBottom: `${designToken("border.width")} solid ${designToken("border.divider")}`,
display: "flex",
fontWeight: designToken("typography.weight-bold"),
gridColumn: "1 / -1",
justifyContent: "space-between",
padding: `${designToken("spacing.md")} ${designToken("spacing.xl")}`,
},
heading: {
fontSize: designToken("typography.size-lg"),
fontWeight: designToken("typography.weight-bold"),
margin: 0,
},
layout: {
background: designToken("background.page"),
color: designToken("text.primary"),
display: "grid",
fontFamily: designToken("typography.family-sans"),
gridTemplateColumns: "15rem minmax(0, 1fr)",
gridTemplateRows: "auto minmax(0, 1fr)",
minHeight: "100vh",
minWidth: 0,
width: "100%",
},
main: {
boxSizing: "border-box",
minWidth: 0,
overflowX: "auto",
padding: designToken("spacing.xl"),
},
message: {
color: designToken("text.secondary"),
display: "grid",
@@ -194,23 +167,10 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
];
}
function ModuleButton(props: {
readonly entry: DashboardNavigationEntry;
readonly selected: boolean;
readonly onSelect: () => void;
}): ReactElement {
return (
<Button
aria-current={props.selected ? "page" : undefined}
aria-pressed={props.selected}
data-testid={`select-${props.entry.descriptor.id}`}
onClick={props.onSelect}
style={{ justifyContent: "flex-start", width: "100%" }}
variant="quiet"
>
{props.entry.name}
</Button>
);
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
* not a page change. */
function routeKey(route: DashboardRoute): string {
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
}
function HostMessage(props: {
@@ -265,21 +225,32 @@ async function resolveBootstrapState(
/**
* The Ekklesia Dashboard shell and its host-owned authentication boundary.
*
* The login form replaces the protected application in place. There is no redirect parameter, URL
* parser, location assignment, or client router in this shell, so the current internal Dashboard URL
* simply remains the current URL through sign-in and no external/protocol-relative return value can
* become a navigation sink. Modules receive the same five-field context after authentication; they
* neither render platform login nor receive sign-in/sign-out controls.
* The login form replaces the protected application in place. There is no redirect parameter,
* return value, or location assignment in this shell, so the current internal Dashboard URL simply
* remains the current URL through sign-in — a deep link opens on its own page once the person has
* signed in — and no external/protocol-relative value can become a navigation sink. The shell only
* ever moves between its own paths (ADR 0057). Modules receive the same context after
* authentication; they neither render platform login nor receive sign-in/sign-out controls.
*/
export function HostApp(props: HostAppProps): ReactElement {
const [state, setState] = useState<BootstrapState>({ status: "loading" });
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
const [bootstrapAttempt, setBootstrapAttempt] = useState(0);
const activeTransitionRef = useRef<AbortController | undefined>(undefined);
const revalidationRef = useRef<AbortController | undefined>(undefined);
const generationRef = useRef(0);
const mountedRef = useRef(true);
const { config, importModule } = props;
const mainRef = useRef<HTMLElement>(null);
const shownRouteRef = useRef<string | undefined>(undefined);
const { config, history, importModule, setDocumentTitle } = props;
// Fallbacks for a composition that supplies neither; created once, so they stay stable.
const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined));
const [fallbackPreference] = useState(() => createMemorySidebarPreference());
const viewport = props.viewport ?? fallbackViewport;
const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
const readPathname = useCallback(() => history.pathname(), [history]);
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
const route = useMemo(() => parseDashboardPath(pathname), [pathname]);
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
// cannot resurrect a screen the person has left.
@@ -306,14 +277,16 @@ export function HostApp(props: HostAppProps): ReactElement {
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
setState(resolved);
if (resolved.status === "ready") {
const first = allEntries(resolved.bootstrap)[0];
setSelectedKey(first ? moduleKey(first) : undefined);
} else {
setSelectedKey(undefined);
}
}, []);
// A non-canonical spelling of a real page (`/example/`) is corrected in place, adding no
// history entry, so what is bookmarked or shared next is the one canonical address.
useEffect(() => {
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
history.replace(dashboardPath(route));
}
}, [history, pathname, route]);
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -329,7 +302,6 @@ export function HostApp(props: HostAppProps): ReactElement {
void bootstrapAttempt;
const { controller, generation } = beginTransition();
setState({ status: "loading" });
setSelectedKey(undefined);
void resolveBootstrapState(config, controller.signal).then((resolved) => {
if (!isCurrent(controller, generation)) {
return;
@@ -381,7 +353,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const { controller, generation } = beginTransition();
// The authenticated subtree disappears before the request starts. That synchronously schedules
// ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions.
setSelectedKey(undefined);
setState({ status: "signing-out" });
try {
await signOutHost(config, controller.signal);
@@ -435,7 +406,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const transition = beginTransition();
transition.controller.abort();
activeTransitionRef.current = undefined;
setSelectedKey(undefined);
setAuthView({ kind: "sign-in" });
setState({ status: "anonymous" });
}
@@ -451,7 +421,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const transition = beginTransition();
transition.controller.abort();
activeTransitionRef.current = undefined;
setSelectedKey(undefined);
console.error("[ekklesia] session revalidation failed");
setState({ status: "failed" });
})
@@ -464,6 +433,41 @@ export function HostApp(props: HostAppProps): ReactElement {
[beginTransition, config],
);
const openEntry =
state.status === "ready" && route.kind === "module"
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
: undefined;
const pageTitle =
state.status !== "ready"
? undefined
: route.kind === "overview"
? "Overview"
: route.kind === "module"
? (openEntry?.name ?? "This page isn’t available")
: "Page not found";
const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle;
useEffect(() => {
if (documentTitle !== undefined) {
setDocumentTitle?.(documentTitle);
}
}, [documentTitle, setDocumentTitle]);
// Moving to another page moves keyboard and screen-reader focus to the new page's content, as a
// full page load would have. The first page shown after sign-in or a refresh is left alone, and a
// canonical respelling of the same page is not a move.
const currentRouteKey = routeKey(route);
useEffect(() => {
if (state.status !== "ready") {
shownRouteRef.current = undefined;
return;
}
if (shownRouteRef.current !== undefined && shownRouteRef.current !== currentRouteKey) {
mainRef.current?.focus();
}
shownRouteRef.current = currentRouteKey;
}, [currentRouteKey, state.status]);
const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1);
if (state.status === "loading") {
@@ -552,74 +556,37 @@ export function HostApp(props: HostAppProps): ReactElement {
}
const { bootstrap } = state;
const entries = allEntries(bootstrap);
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
const { groups, ungrouped } = bootstrap.navigation;
return (
<div data-testid="host-shell" style={shellStyles.layout}>
<header style={shellStyles.header}>
<div style={shellStyles.brand}>
<span aria-hidden="true" style={shellStyles.brandMark} />
<h1 style={shellStyles.heading}>Ekklesia</h1>
</div>
<Button data-testid="host-logout" onClick={() => void signOut()} size="sm">
Sign out
</Button>
</header>
<aside style={shellStyles.aside}>
<DevicePanel config={config} />
{entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : (
<nav aria-label="Modules" style={{ display: "grid", gap: designToken("spacing.xl") }}>
{groups.map((group) => (
<section data-testid={`group-${group.id}`} key={group.id}>
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
<div style={{ display: "grid", gap: "0.125rem" }}>
{group.modules.map((entry) => (
<ModuleButton
entry={entry}
key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))}
selected={moduleKey(entry) === selectedKey}
/>
))}
</div>
</section>
))}
{ungrouped.length > 0 ? (
<section data-testid="group-ungrouped">
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
<div style={{ display: "grid", gap: "0.125rem" }}>
{ungrouped.map((entry) => (
<ModuleButton
entry={entry}
key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))}
selected={moduleKey(entry) === selectedKey}
/>
))}
</div>
</section>
) : undefined}
</nav>
)}
</aside>
<main style={shellStyles.main}>
{selected ? (
<ShellLayout
config={config}
history={history}
mainRef={mainRef}
navigation={bootstrap.navigation}
onSignOut={() => void signOut()}
pageTitle={pageTitle}
route={route}
sidebarPreference={sidebarPreference}
viewport={viewport}
>
{route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry ? (
<ModuleUiFrame
config={config}
descriptor={selected.descriptor}
descriptor={openEntry.descriptor}
{...(importModule ? { importModule } : {})}
key={moduleKey(selected)}
key={moduleKey(openEntry)}
onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal}
/>
) : undefined}
</main>
</div>
) : (
<ModuleUnavailablePage history={history} />
)
) : (
<NotFoundPage history={history} />
)}
</ShellLayout>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { EmptyState, PageHeader } from "@ekklesia/ui";
import type { ReactElement } from "react";
import { DashboardLink } from "./DashboardLink.js";
import type { HostHistory } from "./host-history.js";
/**
* The Dashboard's landing page (DESIGN.md §5, ADR 0057).
*
* The Overview is where the Dashboard opens. Its cards will come from modules through a contract
* that does not exist yet, so for now it honestly says it is empty rather than inventing content.
*/
export function OverviewPage(): ReactElement {
return (
<div data-testid="host-overview">
<PageHeader title="Overview" />
<EmptyState
description="Summaries from your modules will appear here as they become available. Choose a module in the navigation to open it."
title="Nothing to show yet"
/>
</div>
);
}
/**
* A module page whose module is not available to this Dashboard: it was disabled or removed, it
* provides no user interface, or the address was mistyped. The server's navigation is the only
* authority on what exists, and it does not say which of these applies, so neither does this page.
* It never echoes the address back.
*/
export function ModuleUnavailablePage(props: { readonly history: HostHistory }): ReactElement {
return (
<div data-testid="host-module-unavailable">
<PageHeader
actions={<OverviewAction history={props.history} />}
description="The module it belongs to may have been turned off or removed. If you expected to see it, ask an administrator."
title="This page isn’t available"
/>
</div>
);
}
/** Any other address inside the Dashboard. */
export function NotFoundPage(props: { readonly history: HostHistory }): ReactElement {
return (
<div data-testid="host-not-found">
<PageHeader
actions={<OverviewAction history={props.history} />}
description="There is nothing at this address. Check it, or start again from the Overview."
title="Page not found"
/>
</div>
);
}
function OverviewAction(props: { readonly history: HostHistory }): ReactElement {
return (
<DashboardLink
data-testid="host-go-to-overview"
history={props.history}
to={{ kind: "overview" }}
variant="action"
>
Go to Overview
</DashboardLink>
);
}
+122
View File
@@ -0,0 +1,122 @@
import {
type FocusEvent,
type KeyboardEvent,
type MouseEvent,
type ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from "react";
/** How long a tooltip stays after the pointer leaves its control, so the pointer can cross the gap
* onto the tooltip itself (WCAG 2.2 §1.4.13, "hoverable"). */
const HIDE_DELAY_MS = 150;
interface Anchor {
readonly left: number;
readonly top: number;
}
/** The handlers a control spreads onto itself. Typed loosely enough for a link or a button. */
export interface NavTooltipTriggerProps {
onBlur(event: FocusEvent<HTMLElement>): void;
onFocus(event: FocusEvent<HTMLElement>): void;
onKeyDown(event: KeyboardEvent<HTMLElement>): void;
onMouseEnter(event: MouseEvent<HTMLElement>): void;
onMouseLeave(event: MouseEvent<HTMLElement>): void;
}
/**
* The visible name of an icon-only control in the collapsed sidebar (ADR 0058).
*
* The control's accessible name is its own text, kept in the DOM and visually hidden, so the
* tooltip is a **visual** duplicate of that name: it is `aria-hidden`, and a screen reader hears the
* name once. It meets WCAG 2.2 §1.4.13 for content on hover or focus: it appears on keyboard focus as
* well as on hover (nothing is hover-only), Escape dismisses it without moving focus, the pointer can
* move onto it without it vanishing, and it stays until focus or the pointer leaves.
*
* Positioned `fixed` beside the control, from the control's own rectangle, so a scrolling sidebar
* cannot clip it. Disabled, it renders nothing and changes nothing: an expanded sidebar already
* shows every label.
*
* Deliberately shell-local. ADR 0039 adds an element to `@ekklesia/ui` once two independent
* consumers need it; when a module needs a tooltip, this is the shape to promote.
*/
export function useNavTooltip(
label: string,
enabled: boolean,
): { readonly triggerProps: NavTooltipTriggerProps; readonly tooltip: ReactElement | null } {
const [anchor, setAnchor] = useState<Anchor | undefined>(undefined);
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const cancelHide = useCallback(() => {
if (hideTimer.current !== undefined) {
clearTimeout(hideTimer.current);
hideTimer.current = undefined;
}
}, []);
const hide = useCallback(() => {
cancelHide();
setAnchor(undefined);
}, [cancelHide]);
const show = useCallback(
(element: HTMLElement) => {
cancelHide();
const rect = element.getBoundingClientRect();
setAnchor({ left: rect.right + 8, top: rect.top + rect.height / 2 });
},
[cancelHide],
);
useEffect(() => {
if (!enabled) {
hide();
}
}, [enabled, hide]);
useEffect(() => cancelHide, [cancelHide]);
const triggerProps: NavTooltipTriggerProps = {
onBlur: hide,
onFocus: (event) => {
if (enabled) {
show(event.currentTarget);
}
},
onKeyDown: (event) => {
if (event.key === "Escape" && anchor !== undefined) {
// Only this tooltip is dismissed; the event goes no further.
event.stopPropagation();
hide();
}
},
onMouseEnter: (event) => {
if (enabled) {
show(event.currentTarget);
}
},
onMouseLeave: () => {
cancelHide();
hideTimer.current = setTimeout(hide, HIDE_DELAY_MS);
},
};
const tooltip =
enabled && anchor !== undefined ? (
<span
aria-hidden="true"
className="ek-shell-tooltip"
data-testid="nav-tooltip"
onMouseEnter={cancelHide}
onMouseLeave={hide}
style={{ left: anchor.left, top: anchor.top }}
>
{label}
</span>
) : null;
return { tooltip, triggerProps };
}
+378
View File
@@ -0,0 +1,378 @@
import { designToken } from "@ekklesia/design-tokens";
import {
type KeyboardEvent,
type ReactElement,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { DashboardRoute } from "../dashboard-routes.js";
import type { DashboardNavigation, EkklesiaHostConfig } from "../host-protocol.js";
import { AccountMenu } from "./AccountMenu.js";
import { DevicePanel } from "./DevicePanel.js";
import type { HostHistory } from "./host-history.js";
import type { HostViewport } from "./host-viewport.js";
import { useNavTooltip } from "./NavTooltip.js";
import {
controlHeight,
NavigationList,
type NavigationPresentation,
ShellBrand,
} from "./ShellNavigation.js";
import { ShellIcon } from "./shell-icons.js";
import { SHELL_STYLESHEET } from "./shell-styles.js";
import type { SidebarMode, SidebarPreference } from "./sidebar-preference.js";
const SIDEBAR_NAV_ID = "ekklesia-sidebar-navigation";
const DRAWER_ID = "ekklesia-navigation-drawer";
const DRAWER_NAV_ID = "ekklesia-drawer-navigation";
const SIDEBAR_WIDTH: Record<SidebarMode, string> = { collapsed: "4.5rem", expanded: "16rem" };
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export interface ShellLayoutProps {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly viewport: HostViewport;
readonly sidebarPreference: SidebarPreference;
readonly mainRef: RefObject<HTMLElement | null>;
readonly pageTitle: string | undefined;
readonly onSignOut: () => void;
readonly children: ReactNode;
}
function SidebarToggle(props: {
readonly mode: SidebarMode;
readonly onToggle: () => void;
}): ReactElement {
const label = props.mode === "expanded" ? "Collapse navigation" : "Expand navigation";
const { tooltip, triggerProps } = useNavTooltip(label, props.mode === "collapsed");
return (
<>
<button
aria-controls={SIDEBAR_NAV_ID}
aria-expanded={props.mode === "expanded"}
aria-label={label}
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
data-testid="sidebar-toggle"
onClick={props.onToggle}
style={{ minHeight: controlHeight({ collapsed: false, compact: false }) }}
type="button"
{...triggerProps}
>
<ShellIcon name={props.mode === "expanded" ? "collapseSidebar" : "expandSidebar"} />
</button>
{tooltip}
</>
);
}
/**
* The desktop and tablet sidebar (DESIGN.md §4.2): identity at the top, the navigation, and the
* account area anchored at the bottom. Collapsed, it is icons only, each named by a tooltip.
*/
function Sidebar(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly mode: SidebarMode;
readonly onToggle: () => void;
readonly onSignOut: () => void;
}): ReactElement {
const presentation: NavigationPresentation = {
collapsed: props.mode === "collapsed",
compact: false,
};
return (
<aside
className="ek-shell-sidebar"
data-sidebar={props.mode}
data-testid="host-sidebar"
style={{ width: SIDEBAR_WIDTH[props.mode] }}
>
<div className="ek-shell-sidebar__top">
<ShellBrand collapsed={presentation.collapsed} />
<SidebarToggle mode={props.mode} onToggle={props.onToggle} />
</div>
<div className="ek-shell-sidebar__scroll">
<NavigationList
history={props.history}
id={SIDEBAR_NAV_ID}
navigation={props.navigation}
presentation={presentation}
route={props.route}
/>
{/* The transitional device panel (ADR 0036) needs room for its form; collapsed, expanding
the sidebar reveals it. Stage 1 moves it to Settings → Displays. */}
{presentation.collapsed ? undefined : <DevicePanel config={props.config} />}
</div>
<div className="ek-shell-sidebar__bottom">
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} />
</div>
</aside>
);
}
/**
* The phone navigation (DESIGN.md §4.6): the same hierarchy, current page, and account area as the
* sidebar, in a modal drawer.
*
* It is a real modal: `role="dialog"` with `aria-modal`, the page behind it `inert`, and Tab and
* Shift+Tab kept inside it here as well, 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 the
* layout returns focus to the trigger.
*/
function NavigationDrawer(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly onDismiss: () => void;
readonly onNavigate: () => void;
readonly onSignOut: () => void;
}): ReactElement {
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const presentation: NavigationPresentation = { collapsed: false, compact: true };
useEffect(() => {
closeRef.current?.focus();
}, []);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === "Escape") {
event.preventDefault();
props.onDismiss();
return;
}
if (event.key !== "Tab" || !panelRef.current) {
return;
}
const focusable = [...panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)];
const first = focusable[0];
const last = focusable.at(-1);
const active = panelRef.current.ownerDocument.activeElement;
if (event.shiftKey && (active === first || !panelRef.current.contains(active))) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && (active === last || !panelRef.current.contains(active))) {
event.preventDefault();
first?.focus();
}
};
return (
<>
<div
aria-hidden="true"
className="ek-shell-drawer-backdrop"
data-testid="navigation-drawer-backdrop"
onClick={props.onDismiss}
/>
<div
aria-label="Navigation"
aria-modal="true"
className="ek-shell-drawer"
data-testid="navigation-drawer"
id={DRAWER_ID}
onKeyDown={onKeyDown}
ref={panelRef}
role="dialog"
>
<div className="ek-shell-sidebar__top">
<ShellBrand collapsed={false} />
<button
aria-label="Close navigation"
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
data-testid="navigation-drawer-close"
onClick={props.onDismiss}
ref={closeRef}
style={{
minHeight: controlHeight(presentation),
minWidth: controlHeight(presentation),
}}
type="button"
>
<ShellIcon name="closeNavigation" />
</button>
</div>
<div className="ek-shell-sidebar__scroll">
<NavigationList
history={props.history}
id={DRAWER_NAV_ID}
navigation={props.navigation}
onNavigate={props.onNavigate}
presentation={presentation}
route={props.route}
/>
<DevicePanel config={props.config} />
</div>
<div className="ek-shell-sidebar__bottom">
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} />
</div>
</div>
</>
);
}
function routeKey(route: DashboardRoute): string {
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
}
/**
* The authenticated Dashboard's frame (ADR 0058): a collapsible sidebar on wide screens, a drawer
* on phones, and the page.
*
* The page's `<main>` keeps one position in the tree whichever layout is showing, so changing
* layout — rotating a tablet, resizing a window — never remounts the open module. The route comes
* from the URL (ADR 0057) and the desktop mode from the sidebar preference, so neither is touched by
* a layout change; only an open drawer is dropped, and focus that was inside it lands on the page.
* There is no application top bar: on a phone the one control above the page is the menu trigger.
*/
export function ShellLayout(props: ShellLayoutProps): ReactElement {
const { viewport, sidebarPreference, mainRef, route } = props;
const subscribe = useCallback((listener: () => void) => viewport.subscribe(listener), [viewport]);
const readCompact = useCallback(() => viewport.isCompact(), [viewport]);
const compact = useSyncExternalStore(subscribe, readCompact, readCompact);
const [mode, setMode] = useState<SidebarMode>(() => sidebarPreference.read() ?? "expanded");
const [drawerOpen, setDrawerOpen] = useState(false);
const pendingFocus = useRef<"trigger" | "main" | undefined>(undefined);
const triggerRef = useRef<HTMLButtonElement>(null);
const previousCompact = useRef(compact);
const previousRoute = useRef(routeKey(route));
const toggleMode = (): void => {
const next: SidebarMode = mode === "expanded" ? "collapsed" : "expanded";
setMode(next);
sidebarPreference.write(next);
};
const closeDrawer = useCallback((focus: "trigger" | "main") => {
pendingFocus.current = focus;
setDrawerOpen(false);
}, []);
// A layout change drops an open drawer. Focus inside a drawer or sidebar that just went away
// would otherwise fall to the document; it lands on the page instead.
useLayoutEffect(() => {
if (previousCompact.current === compact) {
return;
}
previousCompact.current = compact;
if (drawerOpen) {
closeDrawer("main");
return;
}
const owner = mainRef.current?.ownerDocument;
if (owner && (owner.activeElement === null || owner.activeElement === owner.body)) {
mainRef.current?.focus();
}
}, [closeDrawer, compact, drawerOpen, mainRef]);
// Back and Forward close the drawer too; the shell moves focus to the new page itself.
useEffect(() => {
const key = routeKey(route);
if (previousRoute.current !== key) {
previousRoute.current = key;
setDrawerOpen(false);
}
}, [route]);
useEffect(() => {
if (drawerOpen || pendingFocus.current === undefined) {
return;
}
const target = pendingFocus.current === "trigger" ? triggerRef.current : mainRef.current;
pendingFocus.current = undefined;
target?.focus();
}, [drawerOpen, mainRef]);
const showDrawer = compact && drawerOpen;
const pageInert = showDrawer ? true : undefined;
return (
<div
data-layout={compact ? "compact" : "wide"}
data-testid="host-shell"
style={{
display: compact ? "block" : "grid",
gridTemplateColumns: compact ? undefined : "auto minmax(0, 1fr)",
minHeight: "100vh",
minWidth: 0,
width: "100%",
}}
>
<style>{SHELL_STYLESHEET}</style>
{compact ? undefined : (
<Sidebar
config={props.config}
history={props.history}
mode={mode}
navigation={props.navigation}
onSignOut={props.onSignOut}
onToggle={toggleMode}
route={route}
/>
)}
<div style={{ minWidth: 0 }}>
{compact ? (
<div className="ek-shell-mobile-bar" inert={pageInert}>
<button
aria-controls={DRAWER_ID}
aria-expanded={showDrawer}
aria-label="Open navigation"
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
data-testid="mobile-navigation-trigger"
onClick={() => setDrawerOpen(true)}
ref={triggerRef}
style={{
minHeight: controlHeight({ collapsed: false, compact: true }),
minWidth: controlHeight({ collapsed: false, compact: true }),
}}
type="button"
>
<ShellIcon name="openNavigation" size={24} />
</button>
</div>
) : undefined}
<main
aria-label={props.pageTitle}
data-testid="host-main-region"
inert={pageInert}
ref={mainRef}
style={{
boxSizing: "border-box",
minWidth: 0,
// A programmatic focus target after a page change (never in the tab order).
outline: "none",
overflowX: "auto",
padding: designToken("spacing.xl"),
}}
tabIndex={-1}
>
{props.children}
</main>
</div>
{showDrawer ? (
<NavigationDrawer
config={props.config}
history={props.history}
navigation={props.navigation}
onDismiss={() => closeDrawer("trigger")}
onNavigate={() => closeDrawer("main")}
onSignOut={props.onSignOut}
route={route}
/>
) : undefined}
</div>
);
}
@@ -0,0 +1,208 @@
import { designToken } from "@ekklesia/design-tokens";
import type { CSSProperties, ReactElement } from "react";
import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js";
import type { DashboardNavigation, DashboardNavigationEntry } from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.js";
import type { HostHistory } from "./host-history.js";
import { useNavTooltip } from "./NavTooltip.js";
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
/**
* How a navigation surface presents itself. `collapsed` is the desktop sidebar's icon-only mode;
* `compact` is the phone drawer, which always shows labels and uses larger touch targets.
*/
export interface NavigationPresentation {
readonly collapsed: boolean;
readonly compact: boolean;
}
/** The control height for this presentation: the medium control on desktop, the large one in the
* drawer, so a fingertip has room (WCAG 2.2 §2.5.8 asks for 24px; this is 40px and 48px). */
export function controlHeight(presentation: NavigationPresentation): string {
return designToken(presentation.compact ? "control.height-lg" : "control.height-md");
}
/**
* The identity slot at the top of the navigation (ADR 0058).
*
* A neutral placeholder — a plain mark and the product's name — until the installation's own
* branding replaces it (DESIGN.md §2.1, §3.4). It is one component with no inputs on purpose: the
* branding work owns the decision of what goes here and where it comes from, and replaces this
* component's body rather than undoing a configuration model invented early. It is not a heading;
* each page's own title is. Collapsed, only the mark shows.
*/
export function ShellBrand(props: { readonly collapsed: boolean }): ReactElement {
return (
<div className="ek-shell-brand" data-testid="shell-brand">
<span aria-hidden="true" className="ek-shell-brand__mark" />
<span className={props.collapsed ? "ek-shell-visually-hidden" : undefined}>Ekklesia</span>
</div>
);
}
function NavigationItem(props: {
readonly destination: DashboardDestination;
readonly label: string;
readonly icon: ShellIconName;
readonly current: boolean;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
readonly testId: string;
}): ReactElement {
const { tooltip, triggerProps } = useNavTooltip(props.label, props.presentation.collapsed);
const style: CSSProperties = { minHeight: controlHeight(props.presentation) };
return (
<li>
<DashboardLink
className="ek-shell-nav-item"
current={props.current}
data-testid={props.testId}
history={props.history}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
style={style}
to={props.destination}
{...triggerProps}
>
<ShellIcon name={props.icon} />
<span
className={
props.presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
}
data-nav-label=""
>
{props.label}
</span>
</DashboardLink>
{tooltip}
</li>
);
}
function ModuleItems(props: {
readonly entries: readonly DashboardNavigationEntry[];
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
readonly labelledBy?: string;
}): ReactElement {
return (
<ul
aria-labelledby={props.labelledBy}
className="ek-shell-nav__group"
style={{ listStyle: "none", margin: 0, padding: 0 }}
>
{props.entries.map((entry) => {
const moduleId = entry.descriptor.id;
return (
<NavigationItem
current={props.route.kind === "module" && props.route.moduleId === moduleId}
destination={{ kind: "module", moduleId }}
history={props.history}
// Every module gets the one generic icon until modules can declare their own
// (ADR 0058); there is no table of known modules.
icon="module"
key={`${moduleId}@${entry.descriptor.version}`}
label={entry.name}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
presentation={props.presentation}
testId={`select-${moduleId}`}
/>
);
})}
</ul>
);
}
export interface NavigationListProps {
readonly id: string;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
/** Called once a destination is chosen, for the drawer to close. */
readonly onNavigate?: () => void;
}
/**
* The Dashboard's one navigation hierarchy, identical in the desktop sidebar and the phone drawer
* (DESIGN.md §4.6): the Overview first, then the server's groups in the server's order, then any
* ungrouped modules. The shell sorts and filters nothing — a disabled module is absent because the
* server's navigation does not list it (ADR 0027, ADR 0028).
*
* Collapsed, group labels stay in the DOM for assistive technology and a divider stands in for them
* visually.
*/
export function NavigationList(props: NavigationListProps): ReactElement {
const { groups, ungrouped } = props.navigation;
const { collapsed } = props.presentation;
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
const empty = groups.length === 0 && ungrouped.length === 0;
return (
<nav aria-label="Dashboard" className="ek-shell-nav" id={props.id}>
<ul className="ek-shell-nav__group" style={{ listStyle: "none", margin: 0, padding: 0 }}>
<NavigationItem
current={props.route.kind === "overview"}
destination={{ kind: "overview" }}
history={props.history}
icon="overview"
label="Overview"
{...onNavigate}
presentation={props.presentation}
testId="select-overview"
/>
</ul>
{empty && !collapsed ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : undefined}
{groups.map((group) => {
const labelId = `${props.id}-group-${group.id}`;
return (
<section data-testid={`group-${group.id}`} key={group.id}>
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<h2
className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"}
id={labelId}
>
{group.label}
</h2>
<ModuleItems
entries={group.modules}
history={props.history}
labelledBy={labelId}
{...onNavigate}
presentation={props.presentation}
route={props.route}
/>
</section>
);
})}
{ungrouped.length > 0 ? (
<section data-testid="group-ungrouped">
{groups.length > 0 ? (
<>
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<h2
className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"}
id={`${props.id}-group-other`}
>
Other
</h2>
</>
) : undefined}
<ModuleItems
entries={ungrouped}
history={props.history}
{...(groups.length > 0 ? { labelledBy: `${props.id}-group-other` } : {})}
{...onNavigate}
presentation={props.presentation}
route={props.route}
/>
</section>
) : undefined}
</nav>
);
}
@@ -0,0 +1,32 @@
import type { PageHeaderBack } from "@ekklesia/ui";
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
import { parseReturnTarget } from "../return-target.js";
import type { HostHistory } from "./host-history.js";
export interface DashboardBackOptions {
/** Where the person came from, if a link said so. Validated; anything unsafe is ignored. */
readonly returnTarget?: unknown;
/** The page's own parent: where Back goes when there is no valid return target. */
readonly fallback: DashboardDestination;
/** Names the destination: "Back to Signage". Default "Back". */
readonly label?: (destination: DashboardDestination) => string;
}
/**
* A nested Dashboard page's Back action, for `PageHeader` (DESIGN.md §2.7, §4.8; ADR 0059).
*
* Back returns to the workflow that sent the person here when a validated return target says where
* that was; otherwise it goes to the page's declared parent. It never calls the browser's own
* `history.back()`: after a bookmark or a link from outside, the previous entry is not the
* Dashboard's, and Back must not take the person off the Dashboard. The action is a real link to the
* canonical address, so it can also be opened in a new tab.
*/
export function dashboardBack(history: HostHistory, options: DashboardBackOptions): PageHeaderBack {
const destination = parseReturnTarget(options.returnTarget) ?? options.fallback;
const href = dashboardPath(destination);
return {
href,
label: options.label?.(destination) ?? "Back",
onBack: () => history.push(href),
};
}
+128
View File
@@ -0,0 +1,128 @@
/**
* The shell's one view of the address bar (ADR 0057).
*
* The Dashboard's URL is the source of truth for which page is open: a refresh, a bookmark, and the
* browser's Back and Forward buttons all have to land on the same page that was showing. Everything
* in the shell reads the current path and moves between pages through this small interface rather
* than through `window.history`, for two reasons. It keeps the entry point the only file in this
* package that touches a browser global — the browser implementation is handed the window's pieces,
* it does not reach for them. And it lets every routing behaviour be tested against an in-memory
* history whose entries a test can inspect.
*
* Deliberately not a routing library. The shell's pages are a handful of host-owned screens plus one
* mounted module, and `react-router-dom` is refused to modules precisely because routing belongs to
* the host (ADR 0026); adopting it here would prejudge how a module's own pages are addressed, which
* a later decision owns.
*/
export interface HostHistory {
/** The current path, without query or fragment. */
pathname(): string;
/** Opens a new page, adding a history entry. Opening the page already showing adds none. */
push(path: string): void;
/** Corrects the current entry in place, adding none — for a non-canonical spelling of the same
* page. */
replace(path: string): void;
/** Notifies after every change, whether made here or by the browser's Back and Forward. */
subscribe(listener: () => void): () => void;
}
/** The parts of a `Window` the browser history needs, passed in by the entry point. */
export interface HostHistoryWindow {
readonly location: Pick<Location, "pathname">;
readonly history: Pick<History, "pushState" | "replaceState">;
addEventListener(type: "popstate", listener: () => void): void;
removeEventListener(type: "popstate", listener: () => void): void;
}
/**
* The address bar itself. `pushState` and `replaceState` fire no event of their own, so this
* notifies its subscribers after each one; `popstate` covers Back and Forward.
*/
export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory {
const listeners = new Set<() => void>();
const notify = (): void => {
for (const listener of [...listeners]) {
listener();
}
};
return {
pathname: () => view.location.pathname,
push(path) {
if (path === view.location.pathname) {
return;
}
view.history.pushState(null, "", path);
notify();
},
replace(path) {
view.history.replaceState(null, "", path);
notify();
},
subscribe(listener) {
if (listeners.size === 0) {
view.addEventListener("popstate", notify);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
view.removeEventListener("popstate", notify);
}
};
},
};
}
/** An in-memory history with the same semantics, whose entries a caller can inspect and step
* through. Used by tests; production always uses {@link createBrowserHostHistory}. */
export interface MemoryHostHistory extends HostHistory {
readonly entries: readonly string[];
readonly index: number;
/** Steps like the browser's Back (`-1`) and Forward (`1`) buttons. */
go(delta: number): void;
}
export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory {
const entries: string[] = [initialPath];
let index = 0;
const listeners = new Set<() => void>();
const notify = (): void => {
for (const listener of [...listeners]) {
listener();
}
};
return {
get entries() {
return [...entries];
},
get index() {
return index;
},
pathname: () => entries[index] ?? "/",
push(path) {
if (path === entries[index]) {
return;
}
entries.splice(index + 1, entries.length, path);
index = entries.length - 1;
notify();
},
replace(path) {
entries[index] = path;
notify();
},
go(delta) {
const next = Math.min(Math.max(index + delta, 0), entries.length - 1);
if (next !== index) {
index = next;
notify();
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
@@ -0,0 +1,63 @@
/**
* Whether the Dashboard is laid out for a phone (ADR 0058).
*
* Below the breakpoint the sidebar becomes a drawer with the same navigation; at and above it the
* collapsible sidebar is shown, tablets included (DESIGN.md §4.6–4.7). The shell reads the answer
* through this interface rather than from `window.matchMedia`, so the entry point stays the only
* file that touches a browser global and every responsive behaviour is testable by resizing an
* in-memory viewport.
*/
export interface HostViewport {
isCompact(): boolean;
subscribe(listener: () => void): () => void;
}
/** `em` in a media query is the browser's initial font size, so the breakpoint follows a person's
* own text-size setting: 48em is 768px at the default 16px. */
export const HOST_COMPACT_MEDIA_QUERY = "(max-width: 47.999em)";
type MediaQueryListLike = Pick<MediaQueryList, "matches"> & {
addEventListener(type: "change", listener: () => void): void;
removeEventListener(type: "change", listener: () => void): void;
};
/** The browser's media query, handed in by the entry point. Without one, the wide layout. */
export function createBrowserHostViewport(
matchMedia: ((query: string) => MediaQueryListLike) | undefined,
): HostViewport {
const mediaQuery = matchMedia?.(HOST_COMPACT_MEDIA_QUERY);
return {
isCompact: () => mediaQuery?.matches ?? false,
subscribe(listener) {
mediaQuery?.addEventListener("change", listener);
return () => mediaQuery?.removeEventListener("change", listener);
},
};
}
export interface MemoryHostViewport extends HostViewport {
setCompact(compact: boolean): void;
}
export function createMemoryHostViewport(compact = false): MemoryHostViewport {
let current = compact;
const listeners = new Set<() => void>();
return {
isCompact: () => current,
setCompact(next) {
if (next === current) {
return;
}
current = next;
for (const listener of [...listeners]) {
listener();
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
+41 -2
View File
@@ -4,6 +4,9 @@ import { createRoot } from "react-dom/client";
import { consumeAuthLanding } from "../auth-protocol.js";
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
import { HostApp } from "./HostApp.js";
import { createBrowserHostHistory } from "./host-history.js";
import { createBrowserHostViewport } from "./host-viewport.js";
import { createLocalSidebarPreference } from "./sidebar-preference.js";
/**
* The one place the shell touches a browser global.
@@ -23,6 +26,29 @@ const apiBaseUrl = resolveApiOrigin(
* `consumeAuthLanding` for why the token must not stay in the URL. */
const authLanding = consumeAuthLanding(window.location, window.history);
/** The address bar, handed to the shell once so no other file reads `window.location` (ADR 0057). */
const history = createBrowserHostHistory(window);
const setDocumentTitle = (title: string): void => {
document.title = title;
};
/** The phone-layout media query and this browser's storage, for the sidebar (ADR 0058). Merely
* reading `localStorage` throws where site data is blocked; the shell then remembers the sidebar's
* mode for this page only. */
const viewport = createBrowserHostViewport(
typeof window.matchMedia === "function" ? (query) => window.matchMedia(query) : undefined,
);
const sidebarPreference = createLocalSidebarPreference(
(() => {
try {
return window.localStorage;
} catch {
return undefined;
}
})(),
);
const container = document.getElementById("ekklesia-host");
if (!container) {
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
@@ -53,9 +79,22 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
);
}
return authLanding ? (
<HostApp authLanding={authLanding} config={config} />
<HostApp
authLanding={authLanding}
config={config}
history={history}
setDocumentTitle={setDocumentTitle}
sidebarPreference={sidebarPreference}
viewport={viewport}
/>
) : (
<HostApp config={config} />
<HostApp
config={config}
history={history}
setDocumentTitle={setDocumentTitle}
sidebarPreference={sidebarPreference}
viewport={viewport}
/>
);
}
@@ -0,0 +1,58 @@
import {
Blocks,
CircleUserRound,
LayoutDashboard,
type LucideIcon,
LogOut,
Menu,
PanelLeftClose,
PanelLeftOpen,
X,
} from "lucide-react";
import type { ReactElement } from "react";
/**
* Every icon the shell uses, imported from Lucide in this one file (ADR 0058).
*
* Lucide is Ekklesia's icon vocabulary. Importing each icon by name keeps the bundle to exactly the
* icons named here, and keeping them in one place keeps the vocabulary reviewable: an icon is chosen
* for a meaning, once, rather than per call site.
*
* There is deliberately **no per-module entry**. Every module is drawn with the one generic
* {@link SHELL_ICONS.module} until modules can declare their own icon through the module navigation
* contract; a table of known module ids here would be a second, host-owned answer to a question the
* module should own.
*/
export const SHELL_ICONS = Object.freeze({
account: CircleUserRound,
closeNavigation: X,
collapseSidebar: PanelLeftClose,
expandSidebar: PanelLeftOpen,
module: Blocks,
openNavigation: Menu,
overview: LayoutDashboard,
signOut: LogOut,
} satisfies Record<string, LucideIcon>);
export type ShellIconName = keyof typeof SHELL_ICONS;
/**
* A decorative icon. Always hidden from assistive technology: every control that shows one also
* has a text name, visible or not, so an icon never carries meaning on its own. The stroke follows
* the surrounding text colour, so icons take their colour from the design tokens like text does.
*/
export function ShellIcon(props: {
readonly name: ShellIconName;
readonly size?: number;
}): ReactElement {
const Icon = SHELL_ICONS[props.name];
return (
<Icon
aria-hidden="true"
data-icon={props.name}
focusable="false"
size={props.size ?? 20}
strokeWidth={1.75}
/>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { designToken } from "@ekklesia/design-tokens";
/**
* The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover,
* `:focus-visible`, a reduced-motion override — for the shell's navigation, account menu, tooltip,
* and drawer.
*
* Every value is a design token (ADR 0039), never a literal colour. Every class is `ek-shell-`
* prefixed, so nothing here can collide with `@ekklesia/ui`'s `ek-` elements, and it is installed
* into the shell's own document only: each module artifact still mounts in its own isolated root
* (ADR 0040), which this stylesheet cannot reach.
*/
export const SHELL_STYLESHEET = `
.ek-shell-visually-hidden {
border: 0;
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
.ek-shell-sidebar {
background: ${designToken("background.canvas")};
border-right: ${designToken("border.width")} solid ${designToken("border.divider")};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${designToken("spacing.md")};
height: 100vh;
height: 100dvh;
padding: ${designToken("spacing.md")};
position: sticky;
top: 0;
transition: width 160ms ease;
}
.ek-shell-sidebar__top {
align-items: center;
display: flex;
gap: ${designToken("spacing.sm")};
justify-content: space-between;
min-height: ${designToken("control.height-md")};
}
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__top {
flex-direction: column;
}
.ek-shell-sidebar__scroll {
display: grid;
flex: 1 1 auto;
align-content: start;
gap: ${designToken("spacing.xl")};
min-height: 0;
overflow-y: auto;
}
.ek-shell-sidebar__bottom {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
padding-top: ${designToken("spacing.sm")};
position: relative;
}
.ek-shell-brand {
align-items: center;
color: ${designToken("text.primary")};
display: flex;
font-size: ${designToken("typography.size-md")};
font-weight: ${designToken("typography.weight-bold")};
gap: ${designToken("spacing.sm")};
min-width: 0;
}
.ek-shell-brand__mark {
background: ${designToken("accent.base")};
border-radius: ${designToken("radius.md")};
box-shadow: inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")};
display: block;
flex: 0 0 auto;
height: ${designToken("spacing.xl")};
width: ${designToken("spacing.xl")};
}
.ek-shell-nav,
.ek-shell-nav__group {
display: grid;
gap: ${designToken("spacing.2xs")};
}
.ek-shell-nav {
gap: ${designToken("spacing.lg")};
}
.ek-shell-nav__group-label {
color: ${designToken("text.muted")};
font-size: ${designToken("typography.size-xs")};
font-weight: ${designToken("typography.weight-medium")};
letter-spacing: 0.05em;
margin: 0 0 ${designToken("spacing.2xs")};
padding-inline: ${designToken("control.padding-inline")};
text-transform: uppercase;
}
.ek-shell-nav__divider {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
margin: 0 ${designToken("spacing.xs")};
}
.ek-shell-nav-item {
box-sizing: border-box;
gap: ${designToken("spacing.sm")};
height: auto;
justify-content: flex-start;
text-align: start;
}
.ek-shell-nav-item svg {
flex: 0 0 auto;
}
.ek-shell-nav-item__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-nav-item {
justify-content: center;
padding-inline: 0;
}
.ek-shell-tooltip {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.sm")};
box-shadow: ${designToken("shadow.md")};
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-xs")};
font-weight: ${designToken("typography.weight-medium")};
padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")};
position: fixed;
transform: translateY(-50%);
white-space: nowrap;
z-index: 30;
}
.ek-shell-menu {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
box-shadow: ${designToken("shadow.md")};
bottom: calc(100% + ${designToken("spacing.xs")});
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.2xs")};
left: 0;
margin: 0;
min-width: 12rem;
padding: ${designToken("spacing.xs")};
position: absolute;
z-index: 20;
}
.ek-shell-menu__caption {
color: ${designToken("text.muted")};
font-size: ${designToken("typography.size-xs")};
margin: 0;
padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")};
}
.ek-shell-menu__item {
justify-content: flex-start;
width: 100%;
}
.ek-shell-mobile-bar {
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0;
}
.ek-shell-drawer-backdrop {
background: ${designToken("background.overlay")};
inset: 0;
position: fixed;
z-index: 40;
}
.ek-shell-drawer {
background: ${designToken("background.canvas")};
box-shadow: ${designToken("shadow.lg")};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${designToken("spacing.md")};
inset: 0 auto 0 0;
max-width: 85vw;
outline: none;
padding: ${designToken("spacing.md")};
position: fixed;
width: 20rem;
z-index: 41;
}
@media (prefers-reduced-motion: reduce) {
.ek-shell-sidebar {
transition: none;
}
}
`;
@@ -0,0 +1,70 @@
/**
* Whether the desktop sidebar is expanded or collapsed (ADR 0058).
*
* A preference of **this browser on this device**, not of the person: a narrow laptop and a wide
* office monitor reasonably want different answers, and DESIGN.md's synced personal preferences
* (appearance, motion) belong to the account area, which does not exist yet. The shell reads and
* writes it only through this interface, so a later store — synced, or per person — replaces the
* implementation without touching a component.
*/
export type SidebarMode = "expanded" | "collapsed";
export interface SidebarPreference {
/** The remembered mode, or `undefined` when nothing usable was ever stored. */
read(): SidebarMode | undefined;
write(mode: SidebarMode): void;
}
/** One namespaced key. Names no person and holds nothing but the mode. */
export const SIDEBAR_PREFERENCE_KEY = "ekklesia.dashboard.sidebar";
function isSidebarMode(value: unknown): value is SidebarMode {
return value === "expanded" || value === "collapsed";
}
/**
* The browser's `localStorage`, handed in by the entry point (the only file that touches `window`).
*
* Storage can be missing, blocked, full, or throw on every access (a private window, a sandboxed
* frame, site data cleared or disabled), and none of that may break the Dashboard. Every access is
* guarded, and the last mode chosen on this page is still remembered in memory, so the toggle keeps
* working and only persistence is lost. A stored value that is not exactly one of the two modes is
* ignored rather than trusted.
*/
export function createLocalSidebarPreference(
storage: Pick<Storage, "getItem" | "setItem"> | undefined,
): SidebarPreference {
let remembered: SidebarMode | undefined;
return {
read() {
if (remembered !== undefined) {
return remembered;
}
try {
const stored = storage?.getItem(SIDEBAR_PREFERENCE_KEY);
return isSidebarMode(stored) ? stored : undefined;
} catch {
return undefined;
}
},
write(mode) {
remembered = mode;
try {
storage?.setItem(SIDEBAR_PREFERENCE_KEY, mode);
} catch {
// Persistence is a convenience; the in-memory mode above still holds for this page.
}
},
};
}
/** Nothing persisted: for tests, and for a host composed without browser storage. */
export function createMemorySidebarPreference(initial?: SidebarMode): SidebarPreference {
let mode = initial;
return {
read: () => mode,
write(next) {
mode = next;
},
};
}
+89
View File
@@ -0,0 +1,89 @@
/**
* The Dashboard's URL space (ADR 0057): what each Dashboard path means.
*
* Framework-free and free of browser globals, because two programs read it. The shell parses the
* address bar with it, and `apps/api` serves the Dashboard page at the platform pages it lists, so a
* refreshed or bookmarked deep link reaches the shell instead of a `404`.
*
* Module pages and platform pages share one top-level namespace:
*
* ```text
* / Overview
* /settings[/…] Settings
* /account[/…] the account area
* /<module-id>[/…] a module's page, and later its own sub-pages
* ```
*
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
* Dashboard UI, and Core refuses any module whose id is a platform-owned name (`RESERVED_MODULE_IDS`
* in `@ekklesia/core/modules`), so a module page can never shadow a platform page and an arbitrary
* root path never becomes one.
*/
/**
* The Dashboard's own top-level pages besides the Overview. Reserved whole, including paths no page
* uses yet, so adding a Settings or Account page needs no server or proxy change. Each is also a
* reserved module id in Core; `apps/api` tests hold the two lists together.
*/
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const);
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
export type DashboardRoute =
| { readonly kind: "overview" }
| { readonly kind: "module"; readonly moduleId: string }
| { readonly kind: "not-found" };
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
function decodeSegment(segment: string): string | undefined {
try {
const decoded = decodeURIComponent(segment);
return decoded.length > 0 ? decoded : undefined;
} catch {
return undefined;
}
}
/**
* Reads a path into a route.
*
* A single top-level segment that is not a platform page names a module's page. The id is only
* decoded here, never validated against a pattern: the shell compares it with the navigation the
* server resolved, and an id that matches no entry is an unavailable module, not a malformed URL.
*
* Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), which
* need a contract that does not exist yet, so it is `not-found` for now rather than silently showing
* the module's first page. Settings and Account pages are likewise `not-found` until they exist. One
* trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the
* canonical spelling.
*/
export function parseDashboardPath(pathname: string): DashboardRoute {
if (pathname === "/") {
return { kind: "overview" };
}
const segments = pathname.split("/");
if (segments[0] !== "") {
return { kind: "not-found" };
}
const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1);
const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]);
if (first === undefined || platformPages.has(first)) {
return { kind: "not-found" };
}
if (meaningful.length === 1) {
return { kind: "module", moduleId: first };
}
return { kind: "not-found" };
}
/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
* own path segment. */
export function dashboardPath(route: DashboardDestination): string {
if (route.kind === "overview") {
return "/";
}
return `/${encodeURIComponent(route.moduleId)}`;
}
+196 -18
View File
@@ -1,6 +1,6 @@
import { designToken } from "@ekklesia/design-tokens";
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useEffect, useState } from "react";
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
import {
bootstrapDisplayHost,
type DisplayHostBootstrap,
@@ -17,7 +17,13 @@ import { PairingScreen } from "./PairingScreen.js";
type DisplayState =
| { readonly status: "loading"; readonly generation: number }
| { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } }
| {
readonly status: "ready";
readonly bootstrap: DisplayHostBootstrap & { status: "ready" };
/** Changes only when the mounted display must be replaced, never merely because the network
* came back. It keys the frame, so it is the one thing that can remount a running display. */
readonly generation: number;
}
/** No identity this display can use yet: show a pairing code rather than a human login form. */
| { readonly status: "pairing" }
| { readonly status: "unavailable" }
@@ -31,6 +37,69 @@ export interface DisplayAppProps {
/** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point
* supplies the real ones; omitting it leaves a display that simply never reports being offline. */
readonly connectivity?: DisplayConnectivitySource;
/** How long the display must stay offline before the corner indicator appears. Test seam only. */
readonly offlineIndicatorDelayMs?: number;
/** The shortest interval between two revalidations prompted by refused module requests. Test seam
* only. */
readonly revalidationIntervalMs?: number;
/** How often a running display re-resolves its descriptor on its own. Test seam only. */
readonly periodicRevalidationMs?: number;
/** The first automatic retry delay after a failed start; doubles to a one-minute ceiling. Test seam
* only. */
readonly retryBaseMs?: number;
}
/** A Wi-Fi reassociation or a switch reboot lasts seconds and needs no comment on a lobby wall. Only
* an outage that outlasts this is worth the corner indicator. */
export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000;
/** A module whose requests are being refused prompts at most one revalidation in this interval. A
* display that is merely missing one permission must not turn every refused request into two more. */
export const DISPLAY_REVALIDATION_INTERVAL_MS = 15_000;
/** A running display asks again this often, so a module upgraded on the server is picked up and a
* revocation is noticed even by a module that makes no requests of its own. */
export const DISPLAY_PERIODIC_REVALIDATION_MS = 5 * 60_000;
/** A display that could not start tries again on its own: from this delay, doubling to a minute. A
* kiosk that boots before its server — after a power cut, say — must come up without anyone pressing
* Retry. */
export const DISPLAY_RETRY_BASE_MS = 2_000;
export const DISPLAY_RETRY_MAX_MS = 60_000;
/** Whether a fresh resolution describes exactly the display already mounted. Compared structurally:
* the descriptor and principal are small, server-produced, and carry nothing volatile, so any
* difference — a new version, a new contribution, a different principal — is a real one. */
function sameDisplay(left: DisplayHostBootstrap, right: DisplayHostBootstrap): boolean {
return (
JSON.stringify([left.descriptor, left.principal]) ===
JSON.stringify([right.descriptor, right.principal])
);
}
/** `value`, but only once it has been continuously true for `delayMs`. Becoming false is immediate. */
function useSustained(value: boolean, delayMs: number): boolean {
const [sustained, setSustained] = useState(value && delayMs <= 0);
useEffect(() => {
if (!value) {
setSustained(false);
return;
}
if (delayMs <= 0) {
setSustained(true);
return;
}
const timer = setTimeout(() => setSustained(true), delayMs);
return () => clearTimeout(timer);
}, [delayMs, value]);
return sustained;
}
function isDenied(error: unknown): boolean {
return (
error instanceof HostProtocolError &&
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
);
}
const displayStyles = {
@@ -86,11 +155,13 @@ const displayStyles = {
* recovers on its own the moment connectivity returns.
*/
function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement {
// Neutral wording: this is on a wall the congregation reads, and the product name is the
// installation's to choose, not the platform's (ADR 0046).
const label = props.connectivity.usingFallback
? props.connectivity.fallbackKind === "artifact"
? "Offline — showing the last downloaded display code"
: "Offline — showing the last downloaded Ekklesia display"
: "Offline — waiting for Ekklesia";
: "Offline — showing the last downloaded display"
: "Offline — reconnecting";
return (
<div data-testid="display-offline" role="status" style={displayStyles.offline}>
{label}
@@ -130,7 +201,112 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
online: true,
usingFallback: false,
});
const { config, connectivity: connectivitySource, importModule, moduleId } = props;
const {
config,
connectivity: connectivitySource,
importModule,
moduleId,
offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS,
periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS,
retryBaseMs = DISPLAY_RETRY_BASE_MS,
revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS,
} = props;
/** Consecutive automatic retries since the display last started successfully. */
const failures = useRef(0);
const stateRef = useRef(state);
stateRef.current = state;
const revalidation = useRef<{ inFlight: AbortController | undefined; lastAt: number }>({
inFlight: undefined,
lastAt: 0,
});
/**
* Resolves the descriptor again **without** taking the running display down (ADR 0043).
*
* A display that is showing something keeps showing it while the question is asked. Only the
* answer decides what happens: the same display means nothing changes at all; a new version or a
* different principal replaces the frame; a refusal is a revoked or expired device and returns the
* screen to pairing (ADR 0036); and any other failure is the network still settling, so the frame
* stays. A display that is not showing anything simply starts over.
*/
const revalidate = useCallback(
(reason: "online" | "authorization" | "periodic"): void => {
const current = stateRef.current;
if (current.status !== "ready") {
if (reason === "online") {
setAttempt((value) => value + 1);
}
return;
}
const tracker = revalidation.current;
const nowMs = Date.now();
if (
tracker.inFlight !== undefined ||
(reason === "authorization" && nowMs - tracker.lastAt < revalidationIntervalMs)
) {
return;
}
tracker.lastAt = nowMs;
const abort = new AbortController();
tracker.inFlight = abort;
void bootstrapDisplayHost(config, moduleId, abort.signal)
.then((next) => {
const latest = stateRef.current;
if (abort.signal.aborted || latest.status !== "ready") {
return;
}
if (!sameDisplay(latest.bootstrap, next)) {
setState({ bootstrap: next, generation: latest.generation + 1, status: "ready" });
}
})
.catch((error: unknown) => {
if (!abort.signal.aborted && isDenied(error)) {
setState({ status: "pairing" });
}
})
.finally(() => {
if (tracker.inFlight === abort) {
tracker.inFlight = undefined;
}
});
},
[config, moduleId, revalidationIntervalMs],
);
useEffect(
() => () => {
revalidation.current.inFlight?.abort();
revalidation.current.inFlight = undefined;
},
[],
);
const onAuthorizationResponse = useCallback(() => revalidate("authorization"), [revalidate]);
// While running: ask again now and then, without disturbing anything when the answer is the same.
useEffect(() => {
if (state.status !== "ready" || periodicRevalidationMs <= 0) {
return;
}
const timer = setInterval(() => revalidate("periodic"), periodicRevalidationMs);
return () => clearInterval(timer);
}, [periodicRevalidationMs, revalidate, state.status]);
// Could not start: try again on its own, backing off. Pairing is not a failure — it has its own
// polling — and a ready display has nothing to retry.
useEffect(() => {
if (state.status === "ready") {
failures.current = 0;
return;
}
if (state.status !== "failed" && state.status !== "unavailable") {
return;
}
const delay = Math.min(DISPLAY_RETRY_MAX_MS, retryBaseMs * 2 ** Math.min(failures.current, 10));
failures.current += 1;
const timer = setTimeout(() => setAttempt((value) => value + 1), delay);
return () => clearTimeout(timer);
}, [retryBaseMs, state.status]);
useEffect(() => {
if (!connectivitySource) {
@@ -142,27 +318,29 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
setConnectivity(next);
// Nobody presses Retry on a lobby television. When the browser says the network is back, the
// display resolves its descriptor again — which is also what stops a screen from sitting on
// cached code after the server returns, since the fresh descriptor names the current version
// and the network-first worker fetches it (ADR 0043).
// cached code after the server returns, since a fresh descriptor naming a new version replaces
// the frame and the network-first worker fetches it (ADR 0043). The same answer leaves the
// running display exactly where it was.
if (next.online) {
setAttempt((current) => current + 1);
revalidate("online");
}
});
return () => {
unsubscribe();
monitor.close();
};
}, [connectivitySource]);
}, [connectivitySource, revalidate]);
useEffect(() => {
const abort = new AbortController();
revalidation.current.inFlight?.abort();
setState({ generation: attempt, status: "loading" });
void bootstrapDisplayHost(config, moduleId, abort.signal)
.then((bootstrap) => {
if (abort.signal.aborted) {
return;
}
setState({ bootstrap, status: "ready" });
setState({ bootstrap, generation: attempt, status: "ready" });
})
.catch((error: unknown) => {
if (abort.signal.aborted) {
@@ -170,10 +348,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
}
// A denied or unauthenticated descriptor is the ordinary state of a screen nobody has
// paired yet, not an error worth logging on every wall in the building.
if (
error instanceof HostProtocolError &&
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
) {
if (isDenied(error)) {
setState({ status: "pairing" });
return;
}
@@ -188,12 +363,14 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
}, [attempt, config, moduleId]);
const retry = (): void => setAttempt((current) => current + 1);
const showOffline = useSustained(
!connectivity.online || connectivity.usingFallback,
offlineIndicatorDelayMs,
);
return (
<div data-testid="display-host" style={displayStyles.host}>
{connectivity.online && !connectivity.usingFallback ? undefined : (
<OfflineIndicator connectivity={connectivity} />
)}
{showOffline ? <OfflineIndicator connectivity={connectivity} /> : undefined}
{state.status === "loading" ? (
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
) : undefined}
@@ -215,7 +392,8 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
config={config}
descriptor={state.bootstrap.descriptor}
{...(importModule ? { importModule } : {})}
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`}
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${state.generation}`}
onAuthorizationResponse={onAuthorizationResponse}
principal={state.bootstrap.principal}
/>
) : undefined}
+12 -1
View File
@@ -35,6 +35,9 @@ export interface DisplayUiFrameProps {
readonly principal: ModuleUiPrincipal;
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
readonly importModule?: ModuleUiImporter;
/** Host-internal signal that a module request was refused, so the host can find out whether this
* device was revoked. It never enters the module-facing context. */
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
}
interface DisplayUiAttemptProps extends DisplayUiFrameProps {
@@ -94,7 +97,10 @@ const frameStyles = {
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<FrameStatus>("loading");
const { config, descriptor, importModule, onRetry, principal } = props;
const { config, descriptor, importModule, onAuthorizationResponse, onRetry, principal } = props;
// Read through a ref so a new callback identity can never remount a running display.
const authorizationRef = useRef(onAuthorizationResponse);
authorizationRef.current = onAuthorizationResponse;
useEffect(() => {
const container = containerRef.current;
@@ -162,6 +168,11 @@ function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
context: createModuleUiContext({
config,
descriptor,
onAuthorizationResponse: (status) => {
if (!cancelled) {
authorizationRef.current?.(status);
}
},
principal,
...(importModule ? { importModule } : {}),
}),
+54
View File
@@ -0,0 +1,54 @@
import { type DashboardDestination, parseDashboardPath } from "./dashboard-routes.js";
/** Long enough for any Dashboard address; short enough that nothing else hides in one. */
const MAXIMUM_LENGTH = 2048;
const PLACEHOLDER_ORIGIN = "https://dashboard.invalid";
/**
* Reads a "return to where I came from" value into a Dashboard destination, or refuses it
* (ADR 0059).
*
* A return target is input: it arrives from a link another page or module built, so it is treated
* as hostile until proven otherwise. ADR 0034 requires any return location to be a separately
* validated, same-origin route decision. This is that validation, and it is deliberately narrow:
*
* - a string of at most 2 KiB, beginning with exactly one `/`;
* - no whitespace or control character, no backslash, no percent-encoded slash, backslash, or dot,
* and no dot-segment — nothing a browser would normalize into a different address;
* - no query and no fragment, for now;
* - and it must be a page the Dashboard actually has (`parseDashboardPath` is not `not-found`).
*
* The answer is a destination, never the raw string, so the caller can only ever navigate to the
* canonical spelling of a real page. How a link carries a return target is the module navigation
* contract's decision (ADR 0057); whatever carries it hands the value here first.
*/
export function parseReturnTarget(value: unknown): DashboardDestination | undefined {
if (typeof value !== "string" || value.length === 0 || value.length > MAXIMUM_LENGTH) {
return undefined;
}
if (!value.startsWith("/") || value.startsWith("//")) {
return undefined;
}
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused.
if (/[\s\u0000-\u001f\u007f\\]/u.test(value) || /%(?:2f|5c|2e)/iu.test(value)) {
return undefined;
}
let url: URL;
try {
url = new URL(value, PLACEHOLDER_ORIGIN);
} catch {
return undefined;
}
if (
url.origin !== PLACEHOLDER_ORIGIN ||
url.search !== "" ||
url.hash !== "" ||
value.includes("?") ||
value.includes("#") ||
url.pathname !== value
) {
return undefined;
}
const route = parseDashboardPath(url.pathname);
return route.kind === "not-found" ? undefined : route;
}
+20 -2
View File
@@ -3,6 +3,11 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js";
import {
createLocalSidebarPreference,
SIDEBAR_PREFERENCE_KEY,
} from "../src/browser/sidebar-preference.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
/**
@@ -94,7 +99,16 @@ function platform(stub: AuthStub = {}): Platform {
async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
await act(async () => {
root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />);
root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/")}
// The production store over this browser's real storage, so the assertions below cover
// the shell's one use of it (ADR 0058) as well.
sidebarPreference={createLocalSidebarPreference(window.localStorage)}
{...(authLanding ? { authLanding } : {})}
/>,
);
});
}
@@ -456,7 +470,11 @@ describe("reset password", () => {
// A reset token is a bearer credential for one account. It lives in React state for the life of
// the form and nowhere else.
expect(localStorage.length).toBe(0);
// The only thing this browser's storage may ever hold is the sidebar's mode (ADR 0058).
for (let index = 0; index < localStorage.length; index += 1) {
expect(localStorage.key(index)).toBe(SIDEBAR_PREFERENCE_KEY);
}
expect(JSON.stringify({ ...localStorage })).not.toContain("reset-token-abc");
expect(sessionStorage.length).toBe(0);
expect(text()).not.toContain("reset-token-abc");
expect(container.innerHTML).not.toContain("reset-token-abc");
@@ -0,0 +1,94 @@
// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
DASHBOARD_PLATFORM_PAGES,
dashboardPath,
parseDashboardPath,
} from "../src/dashboard-routes.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("the Dashboard's own top-level pages", () => {
it("are exactly Settings and Account, frozen", () => {
expect(Object.isFrozen(DASHBOARD_PLATFORM_PAGES)).toBe(true);
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
});
});
describe("parseDashboardPath", () => {
it("makes the root the Overview", () => {
expect(parseDashboardPath("/")).toEqual({ kind: "overview" });
});
it("names a module page by its id at the top level, tolerating one trailing slash", () => {
expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
});
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" });
});
it("never reads a platform page as a module page", () => {
for (const pathname of ["/settings", "/settings/", "/account", "/account/appearance"]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
it("treats every other shape as not found rather than guessing", () => {
for (const pathname of [
"",
"//",
"//signage",
"/signage//",
// A module's own sub-pages need a contract that does not exist yet (ADR 0057).
"/signage/timelines",
"/signage/timelines/abc",
"/%E0%A4%A",
"/settings/displays/add",
"signage",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
});
describe("dashboardPath", () => {
it("spells each route canonically, with no implementation prefix", () => {
expect(dashboardPath({ kind: "overview" })).toBe("/");
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/signage");
});
it("encodes a module id so it can never escape its own segment", () => {
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb");
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
kind: "module",
moduleId: "a/../b",
});
});
it("round-trips through the parser", () => {
for (const route of [
{ kind: "overview" } as const,
{ kind: "module", moduleId: "example" } as const,
]) {
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
}
});
});
describe("the route locator is published for the API that serves the page", () => {
it("is a narrow package subpath, not the shell root barrel", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { exports?: Record<string, unknown> };
expect(manifest.exports?.["./dashboard-routes"]).toEqual({
import: "./dist/dashboard-routes.js",
types: "./dist/dashboard-routes.d.ts",
});
});
});

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