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

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

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

ADR 0057 makes the address bar the source of truth:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:31:36 -05:00
73 changed files with 3326 additions and 218 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
+15 -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,19 @@ the module's responsibility and do not exist yet.**
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
Stage 1 begins with **real Dashboard URLs**
([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The
Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own
address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark,
and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages.
The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout
each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves
the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and
discovery refuses a module that uses one. An installed module that is disabled shows a host-owned
*This page isn't available* rather than loading anything. A module's own
sub-pages, Settings and Account pages, the sidebar redesign, and returning to the workflow a Settings
link came from are later Stage 1 work.
## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/api",
"version": "0.2.4",
"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();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/core",
"version": "0.2.4",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
+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";
+33
View File
@@ -0,0 +1,33 @@
/**
* Top-level names the platform owns, which therefore can never be a module id (ADR 0057).
*
* Every module with a Dashboard UI is addressed at `/<module-id>`, beside the platform's own
* top-level namespaces in one URL space. A module called `settings` would collide with the
* Dashboard's Settings pages, and one called `api` with the API itself. This is the complete set the
* platform serves today, not a guess at what it might want later:
*
* - `api` — the API: identity, principal, health, Core resources, module routes, module UI artifacts,
* display descriptors, and realtime (`/api/…`);
* - `assets` — the Dashboard's and display host's scripts (`/assets/ekklesia-dashboard/…`,
* `/assets/ekklesia-display/…`);
* - `display` — the display host's pages and service worker (`/display/…`);
* - `settings` and `account` — the Dashboard's own Settings and account areas.
*
* `apps/api` holds the list to the routes it actually registers, and to the Dashboard namespaces the
* shell interprets, so a new top-level platform route cannot be added without reserving it here.
* Reserving a name is a breaking change for any installation with a module of that id, so the set
* grows only deliberately.
*/
export const RESERVED_MODULE_IDS = Object.freeze([
"account",
"api",
"assets",
"display",
"settings",
] as const);
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);
export function isReservedModuleId(moduleId: string): boolean {
return reserved.has(moduleId);
}
+48
View File
@@ -4,8 +4,10 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
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", {
+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
}
+18 -5
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
@@ -240,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
@@ -262,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 | |
@@ -338,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);
@@ -404,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`. |
@@ -511,10 +524,10 @@ finds its files:
```sh
docker build \
--file deploy/Dockerfile \
--build-arg VERSION=0.2.4 \
--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.4 \
--tag ekklesia:0.2.5 \
.
```
+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.
+3 -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,5 @@ 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
+3 -1
View File
@@ -343,7 +343,9 @@ permission the inventory does; organizing requires the new platform permission
shell renders that arrangement and computes none of it.
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment
Dashboard Home or widgets, no design system, and no client routing (client routing and an Overview
landing page have since landed; see
[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md)). A production CSP and deployment
for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
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
+10
View File
@@ -259,6 +259,16 @@ redirect target and performs no post-login navigation, so external and protocol-
have no redirect sink in this pass. See
[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 -2
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:
@@ -535,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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ekklesia",
"version": "0.2.4",
"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.4",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/design-tokens",
"version": "0.2.4",
"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.4",
"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.4",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-sdk",
"version": "0.2.4",
"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-build",
"version": "0.2.4",
"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.4",
"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.4",
"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.4",
"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.4",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+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
+17 -1
View File
@@ -187,7 +187,9 @@ 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");
if (hasModule) {
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
@@ -224,6 +226,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.
+44
View File
@@ -88,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");
@@ -152,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",
@@ -491,6 +516,25 @@ async function moduleChecks() {
check(installed, `the module inventory lists the \`${moduleId}\` module`);
}
// The module's own Dashboard address (ADR 0057) exists exactly while the module is installed with a
// Dashboard UI — disabled or not: the Dashboard page when it is, the API's sanitized 404 when it is
// not. A disabled module is not running, but its address still opens the Dashboard.
const pageExpectation =
optional("SMOKE_MODULE_PAGE") || (expectation === "absent" ? "absent" : "served");
const modulePage = await fetch(`${dashboardOrigin}/${moduleId}`);
const modulePageBody = await modulePage.text();
if (pageExpectation === "absent") {
check(
modulePage.status === 404 && modulePageBody === '{"code":"NOT_FOUND"}',
`GET /${moduleId} is the sanitized 404 while the module is not installed`,
);
} else {
check(
modulePage.status === 200 && modulePageBody.includes('id="ekklesia-host"'),
`GET /${moduleId} through the proxy is the Dashboard page while the module is installed`,
);
}
if (moduleId === "example") {
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
if (expectation === "absent") {
+27 -1
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
@@ -976,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);
+6 -2
View File
@@ -543,9 +543,13 @@ recreate_api() {
fi
}
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
# 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"
}
@@ -729,7 +733,7 @@ if [[ "$topology" == split ]]; then
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 || fail "the disabled module was still reported as running."
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 \
+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"}}' |
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/shell",
"version": "0.2.4",
"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"
+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 === ""
@@ -0,0 +1,82 @@
import { designToken } from "@ekklesia/design-tokens";
import type { CSSProperties, MouseEvent, ReactElement, ReactNode } from "react";
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
import type { HostHistory } from "./host-history.js";
export interface DashboardLinkProps {
readonly history: HostHistory;
readonly to: DashboardDestination;
readonly children: ReactNode;
/** Marks the link as the page currently open, for assistive technology and visually. */
readonly current?: boolean;
readonly variant?: "navigation" | "action";
readonly "data-testid"?: string;
}
const variantClass = {
action: "ek-button ek-focusable ek-button--secondary",
navigation: "ek-button ek-focusable ek-button--quiet",
} as const;
const navigationStyle: CSSProperties = {
justifyContent: "flex-start",
textDecoration: "none",
width: "100%",
};
/** The open page is shown by weight as well as colour, so it never depends on colour alone. */
const currentStyle: CSSProperties = {
...navigationStyle,
background: designToken("state.selected-background"),
color: designToken("state.selected-foreground"),
fontWeight: designToken("typography.weight-bold"),
};
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
return (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
);
}
/**
* A link to a Dashboard page, as a real `<a href>` (ADR 0057).
*
* A real link rather than a button, because it is one: it can be opened in a new tab, copied, and
* bookmarked, and assistive technology announces it as a link. An ordinary click opens the page in
* place through the shell's history instead of reloading the document; every other click — with a
* modifier key, or the middle button — is left to the browser.
*/
export function DashboardLink(props: DashboardLinkProps): ReactElement {
const href = dashboardPath(props.to);
const variant = props.variant ?? "navigation";
return (
<a
aria-current={props.current ? "page" : undefined}
className={variantClass[variant]}
data-testid={props["data-testid"]}
href={href}
onClick={(event) => {
if (isBrowserHandledClick(event)) {
return;
}
event.preventDefault();
props.history.push(href);
}}
style={
variant === "action"
? { textDecoration: "none" }
: props.current
? currentStyle
: navigationStyle
}
>
{props.children}
</a>
);
}
+41 -3
View File
@@ -1,5 +1,5 @@
import { designToken } from "@ekklesia/design-tokens";
import { 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>
);
}
+160 -79
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,8 +29,11 @@ import {
HostProtocolError,
loadHostPrincipal,
} from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.js";
import { DevicePanel } from "./DevicePanel.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import type { HostHistory } from "./host-history.js";
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js";
@@ -101,6 +113,15 @@ 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;
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter;
}
@@ -167,6 +188,8 @@ const shellStyles = {
main: {
boxSizing: "border-box",
minWidth: 0,
// A programmatic focus target after a page change (never in the tab order), not a control.
outline: "none",
overflowX: "auto",
padding: designToken("spacing.xl"),
},
@@ -194,25 +217,30 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
];
}
function ModuleButton(props: {
function ModuleLink(props: {
readonly entry: DashboardNavigationEntry;
readonly selected: boolean;
readonly onSelect: () => void;
readonly history: HostHistory;
readonly route: DashboardRoute;
}): ReactElement {
const moduleId = props.entry.descriptor.id;
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"
<DashboardLink
current={props.route.kind === "module" && props.route.moduleId === moduleId}
data-testid={`select-${moduleId}`}
history={props.history}
to={{ kind: "module", moduleId }}
>
{props.entry.name}
</Button>
</DashboardLink>
);
}
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
* not a page change. */
function routeKey(route: DashboardRoute): string {
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
}
function HostMessage(props: {
readonly children: string;
readonly testId: string;
@@ -265,21 +293,27 @@ 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;
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 +340,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 +365,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 +416,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 +469,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 +484,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 +496,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") {
@@ -553,7 +620,6 @@ 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 (
@@ -569,56 +635,71 @@ export function HostApp(props: HostAppProps): ReactElement {
</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>
)}
<nav aria-label="Dashboard" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<DashboardLink
current={route.kind === "overview"}
data-testid="select-overview"
history={history}
to={{ kind: "overview" }}
>
Overview
</DashboardLink>
{entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : undefined}
{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) => (
<ModuleLink
entry={entry}
history={history}
key={moduleKey(entry)}
route={route}
/>
))}
</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) => (
<ModuleLink
entry={entry}
history={history}
key={moduleKey(entry)}
route={route}
/>
))}
</div>
</section>
) : undefined}
</nav>
</aside>
<main style={shellStyles.main}>
{selected ? (
<ModuleUiFrame
config={config}
descriptor={selected.descriptor}
{...(importModule ? { importModule } : {})}
key={moduleKey(selected)}
onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal}
/>
) : undefined}
<main aria-label={pageTitle} ref={mainRef} style={shellStyles.main} tabIndex={-1}>
{route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry ? (
<ModuleUiFrame
config={config}
descriptor={openEntry.descriptor}
{...(importModule ? { importModule } : {})}
key={moduleKey(openEntry)}
onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal}
/>
) : (
<ModuleUnavailablePage history={history} />
)
) : (
<NotFoundPage history={history} />
)}
</main>
</div>
);
+66
View File
@@ -0,0 +1,66 @@
import { EmptyState, PageHeader } from "@ekklesia/ui";
import type { ReactElement } from "react";
import { DashboardLink } from "./DashboardLink.js";
import type { HostHistory } from "./host-history.js";
/**
* The Dashboard's landing page (DESIGN.md §5, ADR 0057).
*
* The Overview is where the Dashboard opens. Its cards will come from modules through a contract
* that does not exist yet, so for now it honestly says it is empty rather than inventing content.
*/
export function OverviewPage(): ReactElement {
return (
<div data-testid="host-overview">
<PageHeader title="Overview" />
<EmptyState
description="Summaries from your modules will appear here as they become available. Choose a module in the navigation to open it."
title="Nothing to show yet"
/>
</div>
);
}
/**
* A module page whose module is not available to this Dashboard: it was disabled or removed, it
* provides no user interface, or the address was mistyped. The server's navigation is the only
* authority on what exists, and it does not say which of these applies, so neither does this page.
* It never echoes the address back.
*/
export function ModuleUnavailablePage(props: { readonly history: HostHistory }): ReactElement {
return (
<div data-testid="host-module-unavailable">
<PageHeader
actions={<OverviewAction history={props.history} />}
description="The module it belongs to may have been turned off or removed. If you expected to see it, ask an administrator."
title="This page isn’t available"
/>
</div>
);
}
/** Any other address inside the Dashboard. */
export function NotFoundPage(props: { readonly history: HostHistory }): ReactElement {
return (
<div data-testid="host-not-found">
<PageHeader
actions={<OverviewAction history={props.history} />}
description="There is nothing at this address. Check it, or start again from the Overview."
title="Page not found"
/>
</div>
);
}
function OverviewAction(props: { readonly history: HostHistory }): ReactElement {
return (
<DashboardLink
data-testid="host-go-to-overview"
history={props.history}
to={{ kind: "overview" }}
variant="action"
>
Go to Overview
</DashboardLink>
);
}
+128
View File
@@ -0,0 +1,128 @@
/**
* The shell's one view of the address bar (ADR 0057).
*
* The Dashboard's URL is the source of truth for which page is open: a refresh, a bookmark, and the
* browser's Back and Forward buttons all have to land on the same page that was showing. Everything
* in the shell reads the current path and moves between pages through this small interface rather
* than through `window.history`, for two reasons. It keeps the entry point the only file in this
* package that touches a browser global — the browser implementation is handed the window's pieces,
* it does not reach for them. And it lets every routing behaviour be tested against an in-memory
* history whose entries a test can inspect.
*
* Deliberately not a routing library. The shell's pages are a handful of host-owned screens plus one
* mounted module, and `react-router-dom` is refused to modules precisely because routing belongs to
* the host (ADR 0026); adopting it here would prejudge how a module's own pages are addressed, which
* a later decision owns.
*/
export interface HostHistory {
/** The current path, without query or fragment. */
pathname(): string;
/** Opens a new page, adding a history entry. Opening the page already showing adds none. */
push(path: string): void;
/** Corrects the current entry in place, adding none — for a non-canonical spelling of the same
* page. */
replace(path: string): void;
/** Notifies after every change, whether made here or by the browser's Back and Forward. */
subscribe(listener: () => void): () => void;
}
/** The parts of a `Window` the browser history needs, passed in by the entry point. */
export interface HostHistoryWindow {
readonly location: Pick<Location, "pathname">;
readonly history: Pick<History, "pushState" | "replaceState">;
addEventListener(type: "popstate", listener: () => void): void;
removeEventListener(type: "popstate", listener: () => void): void;
}
/**
* The address bar itself. `pushState` and `replaceState` fire no event of their own, so this
* notifies its subscribers after each one; `popstate` covers Back and Forward.
*/
export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory {
const listeners = new Set<() => void>();
const notify = (): void => {
for (const listener of [...listeners]) {
listener();
}
};
return {
pathname: () => view.location.pathname,
push(path) {
if (path === view.location.pathname) {
return;
}
view.history.pushState(null, "", path);
notify();
},
replace(path) {
view.history.replaceState(null, "", path);
notify();
},
subscribe(listener) {
if (listeners.size === 0) {
view.addEventListener("popstate", notify);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
view.removeEventListener("popstate", notify);
}
};
},
};
}
/** An in-memory history with the same semantics, whose entries a caller can inspect and step
* through. Used by tests; production always uses {@link createBrowserHostHistory}. */
export interface MemoryHostHistory extends HostHistory {
readonly entries: readonly string[];
readonly index: number;
/** Steps like the browser's Back (`-1`) and Forward (`1`) buttons. */
go(delta: number): void;
}
export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory {
const entries: string[] = [initialPath];
let index = 0;
const listeners = new Set<() => void>();
const notify = (): void => {
for (const listener of [...listeners]) {
listener();
}
};
return {
get entries() {
return [...entries];
},
get index() {
return index;
},
pathname: () => entries[index] ?? "/",
push(path) {
if (path === entries[index]) {
return;
}
entries.splice(index + 1, entries.length, path);
index = entries.length - 1;
notify();
},
replace(path) {
entries[index] = path;
notify();
},
go(delta) {
const next = Math.min(Math.max(index + delta, 0), entries.length - 1);
if (next !== index) {
index = next;
notify();
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
+15 -2
View File
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import { consumeAuthLanding } from "../auth-protocol.js";
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
import { HostApp } from "./HostApp.js";
import { createBrowserHostHistory } from "./host-history.js";
/**
* The one place the shell touches a browser global.
@@ -23,6 +24,13 @@ const apiBaseUrl = resolveApiOrigin(
* `consumeAuthLanding` for why the token must not stay in the URL. */
const authLanding = consumeAuthLanding(window.location, window.history);
/** The address bar, handed to the shell once so no other file reads `window.location` (ADR 0057). */
const history = createBrowserHostHistory(window);
const setDocumentTitle = (title: string): void => {
document.title = title;
};
const container = document.getElementById("ekklesia-host");
if (!container) {
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
@@ -53,9 +61,14 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
);
}
return authLanding ? (
<HostApp authLanding={authLanding} config={config} />
<HostApp
authLanding={authLanding}
config={config}
history={history}
setDocumentTitle={setDocumentTitle}
/>
) : (
<HostApp config={config} />
<HostApp config={config} history={history} setDocumentTitle={setDocumentTitle} />
);
}
+89
View File
@@ -0,0 +1,89 @@
/**
* The Dashboard's URL space (ADR 0057): what each Dashboard path means.
*
* Framework-free and free of browser globals, because two programs read it. The shell parses the
* address bar with it, and `apps/api` serves the Dashboard page at the platform pages it lists, so a
* refreshed or bookmarked deep link reaches the shell instead of a `404`.
*
* Module pages and platform pages share one top-level namespace:
*
* ```text
* / Overview
* /settings[/…] Settings
* /account[/…] the account area
* /<module-id>[/…] a module's page, and later its own sub-pages
* ```
*
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
* Dashboard UI, and Core refuses any module whose id is a platform-owned name (`RESERVED_MODULE_IDS`
* in `@ekklesia/core/modules`), so a module page can never shadow a platform page and an arbitrary
* root path never becomes one.
*/
/**
* The Dashboard's own top-level pages besides the Overview. Reserved whole, including paths no page
* uses yet, so adding a Settings or Account page needs no server or proxy change. Each is also a
* reserved module id in Core; `apps/api` tests hold the two lists together.
*/
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const);
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
export type DashboardRoute =
| { readonly kind: "overview" }
| { readonly kind: "module"; readonly moduleId: string }
| { readonly kind: "not-found" };
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
function decodeSegment(segment: string): string | undefined {
try {
const decoded = decodeURIComponent(segment);
return decoded.length > 0 ? decoded : undefined;
} catch {
return undefined;
}
}
/**
* Reads a path into a route.
*
* A single top-level segment that is not a platform page names a module's page. The id is only
* decoded here, never validated against a pattern: the shell compares it with the navigation the
* server resolved, and an id that matches no entry is an unavailable module, not a malformed URL.
*
* Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), which
* need a contract that does not exist yet, so it is `not-found` for now rather than silently showing
* the module's first page. Settings and Account pages are likewise `not-found` until they exist. One
* trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the
* canonical spelling.
*/
export function parseDashboardPath(pathname: string): DashboardRoute {
if (pathname === "/") {
return { kind: "overview" };
}
const segments = pathname.split("/");
if (segments[0] !== "") {
return { kind: "not-found" };
}
const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1);
const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]);
if (first === undefined || platformPages.has(first)) {
return { kind: "not-found" };
}
if (meaningful.length === 1) {
return { kind: "module", moduleId: first };
}
return { kind: "not-found" };
}
/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
* own path segment. */
export function dashboardPath(route: DashboardDestination): string {
if (route.kind === "overview") {
return "/";
}
return `/${encodeURIComponent(route.moduleId)}`;
}
+196 -18
View File
@@ -1,6 +1,6 @@
import { designToken } from "@ekklesia/design-tokens";
import 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 } : {}),
}),
+8 -1
View File
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { 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 type { EkklesiaHostConfig } from "../src/host-protocol.js";
/**
@@ -94,7 +95,13 @@ function platform(stub: AuthStub = {}): Platform {
async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
await act(async () => {
root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />);
root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/")}
{...(authLanding ? { authLanding } : {})}
/>,
);
});
}
@@ -0,0 +1,94 @@
// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
DASHBOARD_PLATFORM_PAGES,
dashboardPath,
parseDashboardPath,
} from "../src/dashboard-routes.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("the Dashboard's own top-level pages", () => {
it("are exactly Settings and Account, frozen", () => {
expect(Object.isFrozen(DASHBOARD_PLATFORM_PAGES)).toBe(true);
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
});
});
describe("parseDashboardPath", () => {
it("makes the root the Overview", () => {
expect(parseDashboardPath("/")).toEqual({ kind: "overview" });
});
it("names a module page by its id at the top level, tolerating one trailing slash", () => {
expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
});
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" });
});
it("never reads a platform page as a module page", () => {
for (const pathname of ["/settings", "/settings/", "/account", "/account/appearance"]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
it("treats every other shape as not found rather than guessing", () => {
for (const pathname of [
"",
"//",
"//signage",
"/signage//",
// A module's own sub-pages need a contract that does not exist yet (ADR 0057).
"/signage/timelines",
"/signage/timelines/abc",
"/%E0%A4%A",
"/settings/displays/add",
"signage",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
});
describe("dashboardPath", () => {
it("spells each route canonically, with no implementation prefix", () => {
expect(dashboardPath({ kind: "overview" })).toBe("/");
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/signage");
});
it("encodes a module id so it can never escape its own segment", () => {
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb");
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
kind: "module",
moduleId: "a/../b",
});
});
it("round-trips through the parser", () => {
for (const route of [
{ kind: "overview" } as const,
{ kind: "module", moduleId: "example" } as const,
]) {
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
}
});
});
describe("the route locator is published for the API that serves the page", () => {
it("is a narrow package subpath, not the shell root barrel", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { exports?: Record<string, unknown> };
expect(manifest.exports?.["./dashboard-routes"]).toEqual({
import: "./dist/dashboard-routes.js",
types: "./dist/dashboard-routes.d.ts",
});
});
});
+106
View File
@@ -0,0 +1,106 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DevicePanel } from "../src/browser/DevicePanel.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
function server() {
let revoked = false;
const revokes: string[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/devices")) {
return json({
devices: [{ id: "device-1", name: "Foyer TV", permissions: [], revoked }],
});
}
if (url.endsWith("/api/v1/devices/device-1/revoke")) {
revoked = true;
revokes.push(url);
return new Response(null, { status: 204 });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { fetch, revokes };
}
function query(testId: string): HTMLElement | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
async function click(testId: string): Promise<void> {
await act(async () => {
query(testId)?.click();
for (let index = 0; index < 5; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
/**
* Revoking a display strands that screen until someone re-pairs it in person, so the panel explains
* the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21).
*/
describe("display device revocation", () => {
it("asks in a product dialog before revoking, and does nothing on cancel", async () => {
const platform = server();
const confirm = vi.fn(() => true);
Object.assign(window, { confirm });
await act(async () => {
root.render(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
});
await click("device-revoke-device-1");
const dialog = container.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Revoke Foyer TV?");
expect(dialog?.textContent).toContain("pairing code");
expect(platform.revokes).toEqual([]);
await click("device-revoke-cancel");
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(platform.revokes).toEqual([]);
expect(confirm).not.toHaveBeenCalled();
});
it("revokes only after the destructive action is confirmed", async () => {
const platform = server();
await act(async () => {
root.render(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
});
await click("device-revoke-device-1");
await click("device-revoke-confirm");
expect(platform.revokes).toHaveLength(1);
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(query("device-device-1")?.textContent).toContain("(revoked)");
});
});
+311 -1
View File
@@ -3,6 +3,7 @@ import { act, Component, type ReactElement, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js";
import { DisplayApp } from "../src/display/DisplayApp.js";
import type {
EkklesiaHostConfig,
@@ -180,6 +181,12 @@ async function render(
importModule?: (url: string) => Promise<unknown>,
moduleId = "announcements",
connectivity?: DisplayConnectivitySource,
timing: {
readonly offlineIndicatorDelayMs?: number;
readonly revalidationIntervalMs?: number;
} = {
offlineIndicatorDelayMs: 0,
},
): Promise<void> {
await act(async () => {
root.render(
@@ -187,6 +194,7 @@ async function render(
config={config}
{...(connectivity ? { connectivity } : {})}
{...(importModule ? { importModule } : {})}
{...timing}
moduleId={moduleId}
/>,
);
@@ -386,7 +394,15 @@ describe("display host — full-viewport boundary", () => {
});
};
await act(async () => root.render(<HostApp config={config} importModule={importer} />));
await act(async () =>
root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/announcements")}
importModule={importer}
/>,
),
);
// The management artifact rendered inside its own isolated root, not the shell's own tree.
expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain(
"announcement management",
@@ -926,3 +942,297 @@ describe("display host — offline behaviour", () => {
expect(query("display-pairing")).not.toBeNull();
});
});
/**
* Stage 0 regressions: a running display survives its network, and a revoked one returns to pairing.
*
* Before these, every browser `online` event tore the module down and mounted it again (a fresh
* viewer session, a fresh stage, every cached object URL released), and a device revoked while its
* display was open kept playing until someone reloaded the page.
*/
describe("display host — continuity across reconnects and revocation", () => {
function controllableConnectivity() {
const listeners = new Map<string, Set<() => void>>();
return {
source: {
addEventListener(type: "online" | "offline", listener: () => void) {
const existing = listeners.get(type) ?? new Set();
existing.add(listener);
listeners.set(type, existing);
},
initialOnline: true,
removeEventListener(type: "online" | "offline", listener: () => void) {
listeners.get(type)?.delete(listener);
},
} satisfies DisplayConnectivitySource,
async emit(type: "online" | "offline") {
await act(async () => {
for (const listener of listeners.get(type) ?? []) {
listener();
}
});
},
};
}
async function flush(): Promise<void> {
await act(async () => {
for (let index = 0; index < 5; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
/** A server whose descriptor answer can be changed mid-test, counting descriptor requests. */
function changingServer() {
let descriptor: { status: number; body: unknown } = { body: descriptorPayload(), status: 200 };
let descriptorCalls = 0;
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/module-displays/announcements")) {
descriptorCalls += 1;
return json(descriptor.body, descriptor.status);
}
if (url.endsWith("/api/v1/device-pairing/requests")) {
return json(
{ code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 },
201,
);
}
if (url.endsWith("/api/v1/device-pairing/claims")) {
return json({ status: "pending" });
}
if (url.endsWith("/api/v1/modules/announcements/refused")) {
return json({ code: "AUTHORIZATION_DENIED" }, 403);
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return {
descriptorCalls: () => descriptorCalls,
fetch,
set(status: number, body: unknown) {
descriptor = { body, status };
},
};
}
function countingArtifact(version = "1.2.0") {
const counts = { mounts: 0, unmounts: 0 };
let context: ModuleUiHostContext | undefined;
const importModule = async (url: string) =>
artifact("announcements", url.includes("/2.0.0/") ? "2.0.0" : version, {
mount(element, mounted) {
counts.mounts += 1;
context = mounted;
element.textContent = "live";
return () => {
counts.unmounts += 1;
};
},
});
return { context: () => context, counts, importModule };
}
it("does not remount a live display when the network drops and returns", async () => {
const server = changingServer();
const connectivity = controllableConnectivity();
const display = countingArtifact();
await render(
{ apiBaseUrl, fetch: server.fetch },
display.importModule,
"announcements",
connectivity.source,
);
await flush();
expect(display.counts.mounts).toBe(1);
const before = server.descriptorCalls();
await connectivity.emit("offline");
await connectivity.emit("online");
await flush();
// The descriptor is still resolved again (ADR 0043) — but the same answer changes nothing.
expect(server.descriptorCalls()).toBeGreaterThan(before);
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
expect(query("display-host-loading")).toBeNull();
});
it("keeps the running display when the descriptor cannot be reached yet", async () => {
const server = changingServer();
const connectivity = controllableConnectivity();
const display = countingArtifact();
await render(
{ apiBaseUrl, fetch: server.fetch },
display.importModule,
"announcements",
connectivity.source,
);
await flush();
server.set(503, { code: "UNAVAILABLE" });
await connectivity.emit("offline");
await connectivity.emit("online");
await flush();
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
expect(query("display-host-error")).toBeNull();
});
it("replaces the display when the server now names a new version", async () => {
const server = changingServer();
const connectivity = controllableConnectivity();
const display = countingArtifact();
await render(
{ apiBaseUrl, fetch: server.fetch },
display.importModule,
"announcements",
connectivity.source,
);
await flush();
server.set(200, descriptorPayload("announcements", "2.0.0"));
await connectivity.emit("offline");
await connectivity.emit("online");
await flush();
expect(display.counts.unmounts).toBe(1);
expect(display.counts.mounts).toBe(2);
});
it("returns a device revoked while its display is open to pairing, without a reload", async () => {
const server = changingServer();
const display = countingArtifact();
await render({ apiBaseUrl, fetch: server.fetch }, display.importModule, "announcements");
await flush();
expect(query("display-module-container")).not.toBeNull();
// The administrator revokes the device. Its next module request is refused.
server.set(403, { code: "AUTHORIZATION_DENIED" });
await act(async () => {
await display.context()?.api.request("refused");
});
await flush();
expect(display.counts.unmounts).toBe(1);
expect(query("display-pairing")).not.toBeNull();
});
it("does not remount when a refused request turns out not to be a revocation", async () => {
const server = changingServer();
const display = countingArtifact();
await render(
{ apiBaseUrl, fetch: server.fetch },
display.importModule,
"announcements",
undefined,
{
offlineIndicatorDelayMs: 0,
revalidationIntervalMs: 60_000,
},
);
await flush();
const before = server.descriptorCalls();
await act(async () => {
await display.context()?.api.request("refused");
await display.context()?.api.request("refused");
await display.context()?.api.request("refused");
});
await flush();
// One revalidation for the burst, and the display stays exactly where it was.
expect(server.descriptorCalls()).toBe(before + 1);
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
});
it("does not show the offline indicator for a momentary blip", async () => {
const server = changingServer();
const connectivity = controllableConnectivity();
await render(
{ apiBaseUrl, fetch: server.fetch },
countingArtifact().importModule,
"announcements",
connectivity.source,
{ offlineIndicatorDelayMs: 60 },
);
await flush();
await connectivity.emit("offline");
await connectivity.emit("online");
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 120));
});
expect(query("display-offline")).toBeNull();
await connectivity.emit("offline");
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 120));
});
expect(query("display-offline")?.textContent).toBe("Offline — reconnecting");
});
it("starts on its own when the server comes up after the screen did", async () => {
// A kiosk that boots before its server: the network is up, so no `online` event will ever come.
let up = false;
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/module-displays/announcements")) {
return up ? json(descriptorPayload()) : json({ code: "UNAVAILABLE" }, 503);
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
const display = countingArtifact();
await act(async () => {
root.render(
<DisplayApp
config={{ apiBaseUrl, fetch }}
importModule={display.importModule}
moduleId="announcements"
offlineIndicatorDelayMs={0}
retryBaseMs={20}
/>,
);
});
await flush();
expect(query("display-host-error")).not.toBeNull();
up = true;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
await flush();
expect(display.counts.mounts).toBe(1);
expect(query("display-host-error")).toBeNull();
});
it("re-resolves a running display periodically, remounting only for a new version", async () => {
const server = changingServer();
const display = countingArtifact();
await act(async () => {
root.render(
<DisplayApp
config={{ apiBaseUrl, fetch: server.fetch }}
importModule={display.importModule}
moduleId="announcements"
offlineIndicatorDelayMs={0}
periodicRevalidationMs={40}
/>,
);
});
await flush();
const before = server.descriptorCalls();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
await flush();
expect(server.descriptorCalls()).toBeGreaterThan(before);
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
server.set(200, descriptorPayload("announcements", "2.0.0"));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
await flush();
expect(display.counts.mounts).toBe(2);
});
});
+59 -9
View File
@@ -3,6 +3,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createBrowserHostHistory } from "../src/browser/host-history.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
@@ -122,12 +123,27 @@ function artifact(id: string, version: string, options: ArtifactOptions = {}) {
};
}
/**
* Renders the shell over the real (happy-dom) address bar. `path` opens that page first, exactly as
* a deep link or a refresh would (ADR 0057); left out, the shell opens wherever the location already
* is — `/`, the Overview, unless a test put something else there.
*/
async function render(
config: EkklesiaHostConfig,
importModule?: (url: string) => Promise<unknown>,
path?: string,
): Promise<void> {
if (path !== undefined) {
window.history.replaceState(null, "", path);
}
await act(async () => {
root.render(<HostApp config={config} {...(importModule ? { importModule } : {})} />);
root.render(
<HostApp
config={config}
history={createBrowserHostHistory(window)}
{...(importModule ? { importModule } : {})}
/>,
);
});
}
@@ -268,6 +284,11 @@ describe("Ekklesia shell — bootstrap", () => {
});
describe("Ekklesia shell — browser authentication", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/example");
});
it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
let releasePrincipal: (() => void) | undefined;
const principalPending = new Promise<void>((resolve) => {
@@ -539,6 +560,11 @@ describe("Ekklesia shell — browser authentication", () => {
});
describe("Ekklesia shell — logout and session expiration", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/example");
});
it("unmounts module UI and closes realtime before completing server-side logout", async () => {
let releaseSignOut: (() => void) | undefined;
const signOutPending = new Promise<void>((resolve) => {
@@ -856,10 +882,8 @@ describe("Ekklesia shell — Dashboard navigation", () => {
const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent);
expect(labels).toEqual(["Planning", "Media"]);
const buttons = [...container.querySelectorAll("nav button")].map(
(button) => button.textContent,
);
expect(buttons).toEqual(["Calendar", "Livestream", "Video"]);
const links = [...container.querySelectorAll("nav section a")].map((link) => link.textContent);
expect(links).toEqual(["Calendar", "Livestream", "Video"]);
});
it("renders ungrouped modules alongside groups, under a heading", async () => {
@@ -897,14 +921,29 @@ describe("Ekklesia shell — Dashboard navigation", () => {
expect(query("group-media")?.textContent).toBe("Media");
});
it("selects the first module in the arranged order and marks it active", async () => {
it("opens on the Overview rather than choosing a module, whatever the arrangement", async () => {
const importModule = vi.fn(async () => artifact("video", "0.1.0"));
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async (url) => artifact(url.includes("/video/") ? "video" : "other", "0.1.0"),
importModule,
);
expect(query("select-video")?.getAttribute("aria-current")).toBe("page");
expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull();
expect(query("select-overview")?.getAttribute("aria-current")).toBe("page");
expect(query("select-video")?.getAttribute("aria-current")).toBeNull();
expect(query("module-container")).toBeNull();
expect(importModule).not.toHaveBeenCalled();
});
it("marks the open module's link as the current page", async () => {
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async () => artifact("livestream", "2.0.0"),
"/livestream",
);
expect(query("select-livestream")?.getAttribute("aria-current")).toBe("page");
expect(query("select-video")?.getAttribute("aria-current")).toBeNull();
expect(query("select-overview")?.getAttribute("aria-current")).toBeNull();
});
it("mounts a module selected from inside a group, using the descriptor the group carried", async () => {
@@ -915,6 +954,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
requested.push(url);
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
},
"/video",
);
await act(async () => {
@@ -972,6 +1012,11 @@ describe("Ekklesia shell — Dashboard navigation", () => {
});
describe("Ekklesia shell — mount lifecycle", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/example");
});
it("keeps fixed shell tracks while module content changes size", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0"));
@@ -1140,6 +1185,11 @@ describe("Ekklesia shell — mount lifecycle", () => {
});
describe("Ekklesia shell — failure is host-owned", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/example");
});
it("renders its own error when the artifact cannot be imported", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing");
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createBrowserHostHistory, createMemoryHostHistory } from "../src/browser/host-history.js";
describe("the browser host history", () => {
beforeEach(() => {
window.history.replaceState(null, "", "/");
});
afterEach(() => {
window.history.replaceState(null, "", "/");
});
it("reads the address bar's path and moves it with real history entries", () => {
const history = createBrowserHostHistory(window);
const lengthBefore = window.history.length;
history.push("/example");
expect(history.pathname()).toBe("/example");
expect(window.location.pathname).toBe("/example");
expect(window.history.length).toBe(lengthBefore + 1);
});
it("adds no entry when the page asked for is already open", () => {
const history = createBrowserHostHistory(window);
history.push("/example");
const length = window.history.length;
const listener = vi.fn();
history.subscribe(listener);
history.push("/example");
expect(window.history.length).toBe(length);
expect(listener).not.toHaveBeenCalled();
});
it("corrects the current entry in place with replace", () => {
const history = createBrowserHostHistory(window);
history.push("/example/");
const length = window.history.length;
history.replace("/example");
expect(window.location.pathname).toBe("/example");
expect(window.history.length).toBe(length);
});
it("notifies after its own changes and after the browser's Back and Forward", () => {
const history = createBrowserHostHistory(window);
const listener = vi.fn();
const unsubscribe = history.subscribe(listener);
history.push("/a");
history.replace("/b");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(listener).toHaveBeenCalledTimes(3);
unsubscribe();
window.dispatchEvent(new PopStateEvent("popstate"));
history.push("/c");
expect(listener).toHaveBeenCalledTimes(3);
});
it("listens to popstate only while something is subscribed", () => {
const view = {
addEventListener: vi.fn(),
history: { pushState: vi.fn(), replaceState: vi.fn() },
location: { pathname: "/" },
removeEventListener: vi.fn(),
};
const history = createBrowserHostHistory(view);
const first = history.subscribe(() => undefined);
const second = history.subscribe(() => undefined);
expect(view.addEventListener).toHaveBeenCalledTimes(1);
first();
expect(view.removeEventListener).not.toHaveBeenCalled();
second();
expect(view.removeEventListener).toHaveBeenCalledTimes(1);
expect(view.removeEventListener.mock.calls[0]?.[1]).toBe(
view.addEventListener.mock.calls[0]?.[1],
);
});
it("stores no state object, so nothing a page put in history can be read back as authority", () => {
const view = {
addEventListener: vi.fn(),
history: { pushState: vi.fn(), replaceState: vi.fn() },
location: { pathname: "/" },
removeEventListener: vi.fn(),
};
const history = createBrowserHostHistory(view);
history.push("/a");
history.replace("/b");
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/a");
expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/b");
});
});
describe("the in-memory host history", () => {
it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => {
const history = createMemoryHostHistory("/");
history.push("/a");
history.push("/b");
history.go(-1);
expect(history.pathname()).toBe("/a");
history.push("/c");
expect(history.entries).toEqual(["/", "/a", "/c"]);
history.go(-10);
expect(history.pathname()).toBe("/");
history.go(10);
expect(history.pathname()).toBe("/c");
});
});
+360
View File
@@ -0,0 +1,360 @@
import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedText } from "./support/isolated-dom.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* The Dashboard's URL is the source of truth for which page is open (ADR 0057). Every test drives
* the real shell through an in-memory history, so it can assert on the exact entries a person's
* Back and Forward buttons would walk.
*/
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
});
function entry(id: string, name: string, version = "0.1.0") {
return {
module: {
api: { baseUrl: `/api/v1/modules/${id}` },
contributions: [],
display: null,
id,
permissions: [],
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
ui: { url: `/api/v1/module-ui/${id}/${version}` },
version,
},
name,
};
}
const NAVIGATION = {
groups: [{ id: "media", label: "Media", modules: [entry("signage", "Signage")], order: 10 }],
ungrouped: [entry("calendar", "Calendar", "2.0.0")],
};
function platform(options: { authenticated?: boolean } = {}) {
let authenticated = options.authenticated ?? true;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/auth/sign-in/email")) {
void init;
authenticated = true;
return Response.json({ ok: true });
}
if (url.endsWith("/api/v1/principal")) {
return Response.json(
authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
);
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return authenticated
? Response.json(NAVIGATION)
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
return Response.json({ code: "NOT_FOUND" }, { status: 404 });
}) as unknown as typeof fetch;
return fetchImplementation;
}
function artifactFor(url: string) {
const id = url.includes("/calendar/") ? "calendar" : "signage";
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id,
mount(element: Element, context: ModuleUiHostContext) {
element.textContent = `${context.moduleId} mounted`;
return () => {
element.textContent = "";
};
},
version: id === "calendar" ? "2.0.0" : "0.1.0",
},
};
}
interface Rendered {
readonly history: MemoryHostHistory;
readonly imported: string[];
readonly titles: string[];
}
async function render(
path: string,
options: { authenticated?: boolean; history?: MemoryHostHistory } = {},
): Promise<Rendered> {
const history = options.history ?? createMemoryHostHistory(path);
const imported: string[] = [];
const titles: string[] = [];
const config: EkklesiaHostConfig = { apiBaseUrl, fetch: platform(options) };
await act(async () => {
root.render(
<HostApp
config={config}
history={history}
importModule={async (url) => {
imported.push(url);
return artifactFor(url);
}}
setDocumentTitle={(title) => titles.push(title)}
/>,
);
});
return { history, imported, titles };
}
function query(testId: string): HTMLElement | null {
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
}
async function click(element: Element | null, init: MouseEventInit = {}): Promise<MouseEvent> {
if (!element) {
throw new Error("missing element");
}
const event = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init });
await act(async () => {
element.dispatchEvent(event);
});
return event;
}
async function go(history: MemoryHostHistory, delta: number): Promise<void> {
await act(async () => {
history.go(delta);
});
}
describe("the Dashboard opens on the page its URL names", () => {
it("lands on the Overview at the root, and mounts no module", async () => {
const { imported, titles } = await render("/");
expect(query("host-overview")).not.toBeNull();
expect(query("host-overview")?.querySelector("h1")?.textContent).toBe("Overview");
expect(query("module-container")).toBeNull();
expect(imported).toEqual([]);
expect(query("select-overview")?.getAttribute("aria-current")).toBe("page");
expect(titles.at(-1)).toBe("Overview");
});
it("opens a module's page directly from its deep link — what a refresh or a bookmark does", async () => {
const { imported, titles } = await render("/calendar");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
expect(query("select-calendar")?.getAttribute("aria-current")).toBe("page");
expect(query("select-overview")?.getAttribute("aria-current")).toBeNull();
expect(query("host-overview")).toBeNull();
expect(titles.at(-1)).toBe("Calendar");
});
it("opens a module from inside a group exactly as one outside it", async () => {
const { imported } = await render("/signage");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`]);
expect(query("select-signage")?.getAttribute("aria-current")).toBe("page");
});
it("says a module page is unavailable when no navigable module has that id, and loads nothing", async () => {
const { imported, titles } = await render("/disabled-module");
expect(query("host-module-unavailable")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(imported).toEqual([]);
// The address is never echoed back into the page.
expect(container.textContent).not.toContain("disabled-module");
expect(titles.at(-1)).toBe("This page isn’t available");
expect(query("host-go-to-overview")?.getAttribute("href")).toBe("/");
});
it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => {
for (const path of ["/settings/displays", "/calendar/events", "/account"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
const { imported, titles } = await render(path);
expect(query("host-not-found"), path).not.toBeNull();
expect(imported, path).toEqual([]);
expect(titles.at(-1), path).toBe("Page not found");
// Nothing in the navigation claims to be the open page.
expect(container.querySelector('[aria-current="page"]'), path).toBeNull();
}
});
it("addresses module pages at the top level, beside the platform's own pages, and never confuses the two", async () => {
// Core refuses a module whose id is a platform page, but even a navigation that somehow listed
// one could not turn `/settings` into a module page.
const { imported } = await render("/settings");
expect(query("host-not-found")).not.toBeNull();
expect(imported).toEqual([]);
expect(query("select-signage")?.getAttribute("href")).toBe("/signage");
expect(container.innerHTML).not.toContain("/modules/");
});
it("corrects a trailing slash to the canonical address in place, adding no history entry", async () => {
const { history, imported } = await render("/calendar/");
expect(history.entries).toEqual(["/calendar"]);
expect(imported).toHaveLength(1);
});
it("opens the deep link it was given once the person signs in, with no redirect", async () => {
const history = createMemoryHostHistory("/calendar");
const { imported } = await render("/calendar", { authenticated: false, history });
expect(query("host-login")).not.toBeNull();
const email = query("login-email") as HTMLInputElement;
const password = query("login-password") as HTMLInputElement;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
await act(async () => {
setter?.call(email, "[email protected]");
email.dispatchEvent(new Event("input", { bubbles: true }));
setter?.call(password, "correct password");
password.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
query("host-login")
?.querySelector("form")
?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(history.entries).toEqual(["/calendar"]);
});
});
describe("moving between pages", () => {
it("renders navigation as real links to canonical addresses", async () => {
await render("/");
for (const [testId, href] of [
["select-overview", "/"],
["select-signage", "/signage"],
["select-calendar", "/calendar"],
] as const) {
const link = query(testId);
expect(link?.tagName, testId).toBe("A");
expect(link?.getAttribute("href"), testId).toBe(href);
}
expect(container.querySelector('nav[aria-label="Dashboard"]')).not.toBeNull();
});
it("opens a page in place on an ordinary click, adding one history entry", async () => {
const { history, imported } = await render("/");
const event = await click(query("select-calendar"));
expect(event.defaultPrevented).toBe(true);
expect(history.entries).toEqual(["/", "/calendar"]);
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
});
it("leaves a modified or middle click to the browser, so a page can open in a new tab", async () => {
const { history } = await render("/");
for (const init of [
{ ctrlKey: true },
{ metaKey: true },
{ shiftKey: true },
{ altKey: true },
{ button: 1 },
]) {
const event = await click(query("select-calendar"), init);
expect(event.defaultPrevented, JSON.stringify(init)).toBe(false);
}
expect(history.entries).toEqual(["/"]);
});
it("adds no entry when the open page's own link is clicked again", async () => {
const { history } = await render("/calendar");
await click(query("select-calendar"));
expect(history.entries).toEqual(["/calendar"]);
});
it("walks Back and Forward through exactly the pages that were open", async () => {
const { history, imported } = await render("/");
await click(query("select-signage"));
await click(query("select-calendar"));
await go(history, -1);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
expect(query("select-signage")?.getAttribute("aria-current")).toBe("page");
await go(history, -1);
expect(query("host-overview")).not.toBeNull();
expect(query("module-container")).toBeNull();
await go(history, 1);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
expect(imported).toEqual([
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`,
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
]);
});
it("returns to the Overview from an unavailable page through its own link", async () => {
const { history } = await render("/missing");
await click(query("host-go-to-overview"));
expect(history.entries).toEqual(["/missing", "/"]);
expect(query("host-overview")).not.toBeNull();
});
it("moves focus to the new page's content after a page change, but not on first load", async () => {
await render("/");
const main = container.querySelector("main");
expect(main?.getAttribute("tabindex")).toBe("-1");
expect(document.activeElement).not.toBe(main);
await click(query("select-calendar"));
expect(document.activeElement).toBe(main);
expect(main?.getAttribute("aria-label")).toBe("Calendar");
});
it("names the browser tab after each page as it opens", async () => {
const { titles } = await render("/");
await click(query("select-signage"));
await click(query("select-overview"));
expect(titles).toEqual(["Overview", "Signage", "Overview"]);
});
});
+1
View File
@@ -11,6 +11,7 @@
"include": [
"src/index.ts",
"src/dashboard-assets.ts",
"src/dashboard-routes.ts",
"src/display-assets.ts",
"src/host-protocol.ts",
"src/module-context.ts"
+28
View File
@@ -128,6 +128,34 @@ describe("CI scripts on a shared Docker daemon", () => {
expect(workflow.jobs["deploy-smoke"]?.env?.EKKLESIA_CI_JOB_KEY).toContain("matrix.topology");
});
it("never sweep another run's live stack early, not even from the sweep's own self-test", async () => {
// The workflow's stale sweeps keep the default threshold, which is far above any job's run time.
const workflow = await source(".github/workflows/ci.yml");
const workflowSweeps = workflow
.split("\n")
.filter((line) => line.includes("ci-sweep.sh --stale"));
expect(workflowSweeps.length).toBeGreaterThan(0);
for (const line of workflowSweeps) {
expect(line).not.toContain("--max-age-seconds");
}
// The self-test does use short thresholds, so every stale sweep it runs is confined to its own
// fake run. Unconfined, `--max-age-seconds 0` removed every CI stack on the shared daemon —
// including a module repository's integration stack mid-run.
const selfTest = await source("scripts/test-ci-sweep.sh");
expect(selfTest).toMatch(/^own_run="\$\(ci_name_safe "ekklesia-ci-\$\{GITHUB_RUN_ID\}-"\)"$/mu);
expect(selfTest.indexOf('export GITHUB_RUN_ID="sweeptest')).toBeLessThan(
selfTest.indexOf("own_run="),
);
const selfTestSweeps = selfTest
.split("\n")
.filter((line) => /^\s*bash scripts\/ci-sweep\.sh --stale/u.test(line));
expect(selfTestSweeps.length).toBeGreaterThan(0);
for (const line of selfTestSweeps) {
expect(line).toContain('--prefix "$own_run"');
}
});
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
+64
View File
@@ -189,6 +189,12 @@ describe("reference deployment compose files", () => {
.join("\n");
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
// The Dashboard's other pages (ADR 0057): every top-level name shaped like a module id, and
// nothing else, so the API — which knows the installed modules — decides page or 404.
expect(directives).toContain(
`@dashboardPage path_regexp dashboardPage ${DASHBOARD_PAGE_PATTERN}`,
);
expect(directives).toMatch(/handle @dashboardPage \{\s*reverse_proxy api:3000 \{/u);
expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
expect(directives).toContain("header_up -CF-Connecting-IP");
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
@@ -200,6 +206,64 @@ describe("reference deployment compose files", () => {
});
});
/** The proxies' Dashboard-page matcher: a module id's grammar (`@ekklesia/contracts`) as the first
* segment, then anything. Checked against real paths below so the regular expression itself is
* tested, not just its spelling. */
const DASHBOARD_PAGE_PATTERN = "^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$";
describe("the proxies' Dashboard-page matcher", () => {
const matcher = new RegExp(DASHBOARD_PAGE_PATTERN, "u");
it("forwards every Dashboard page and module-shaped name", () => {
for (const path of [
"/settings",
"/settings/displays/add",
"/account",
"/signage",
"/signage/",
"/signage/timelines",
"/sunday-production",
"/definitely-not-a-real-module",
]) {
expect(matcher.test(path), path).toBe(true);
}
});
it("forwards nothing that could not be a module id", () => {
for (const path of [
"/",
"/favicon.ico",
"/robots.txt",
"/.well-known/x",
"/Signage",
"/-x",
"/1x",
"//signage",
"/a--b",
]) {
expect(matcher.test(path), path).toBe(false);
}
});
});
describe("split-origin reference proxy", () => {
it("serves the Dashboard's page, its deep links, and its script on the Dashboard hostname, and no API", async () => {
const caddyfile = await text("Caddyfile.split.example");
const directives = caddyfile
.split("\n")
.filter((line) => !line.trim().startsWith("#"))
.join("\n");
expect(directives).toContain("@dashboard path / /assets/ekklesia-dashboard/*");
expect(directives).toContain(`path_regexp dashboardPage ${DASHBOARD_PAGE_PATTERN}`);
// The API's own namespaces stay refused on the Dashboard hostname even though they are shaped
// like a module id.
expect(directives).toContain("not path /api /api/* /display /display/* /assets /assets/*");
// The API hostname still serves no Dashboard page at all.
expect(directives).toContain("@api path /api/* /display/* /assets/ekklesia-display/*");
});
});
describe("reference deployment env examples", () => {
it("contain no secret values", async () => {
const secretKeys = new Map([
+8 -8
View File
@@ -13,18 +13,18 @@
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json"
},
"dependencies": {
"@ekklesia/design-tokens": "^0.2.4",
"@ekklesia/module-database": "^0.2.4",
"@ekklesia/module-sdk": "^0.2.4",
"@ekklesia/module-ui-sdk": "^0.2.4",
"@ekklesia/ui": "^0.2.4",
"@ekklesia/design-tokens": "^0.2.5",
"@ekklesia/module-database": "^0.2.5",
"@ekklesia/module-sdk": "^0.2.5",
"@ekklesia/module-ui-sdk": "^0.2.5",
"@ekklesia/ui": "^0.2.5",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@ekklesia/module-pack": "0.2.4",
"@ekklesia/module-ui-build": "^0.2.4",
"@ekklesia/typescript-config": "^0.2.4",
"@ekklesia/module-pack": "0.2.5",
"@ekklesia/module-ui-build": "^0.2.5",
"@ekklesia/typescript-config": "^0.2.5",
"@types/node": "24.13.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",