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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:31:36 -05:00
The_River ae976ca229 Merge pull request 'chore(release): prepare 0.2.4' (#91) from chore/release-0.2.4 into main
CI / verify (push) Successful in 1m13s
CI / infrastructure-smoke (push) Successful in 38s
CI / package-release (push) Successful in 49s
CI / image-build (push) Successful in 30s
CI / migration-smoke (push) Successful in 2m13s
CI / deploy-smoke (same-origin) (push) Successful in 47s
CI / package-publication (push) Successful in 1m4s
CI / deploy-smoke (split) (push) Successful in 2m23s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/91
Reviewed-by: The_River <[email protected]>
2026-10-10 17:09:10 +00:00
ekklesia f9585cc7ec chore(release): prepare 0.2.4
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / package-release (pull_request) Successful in 34s
CI / image-build (pull_request) Successful in 59s
CI / deploy-smoke (same-origin) (pull_request) Successful in 47s
CI / deploy-smoke (split) (pull_request) Successful in 2m21s
CI / package-publication (pull_request) Successful in 49s
2026-10-10 11:54:00 -05:00
The_River 6dae29412f Merge pull request 'test(deploy): exercise external module packages' (#90) from test/external-module-deploy-smoke into main
CI / verify (push) Successful in 1m3s
CI / infrastructure-smoke (push) Successful in 35s
CI / migration-smoke (push) Successful in 1m54s
CI / package-release (push) Successful in 34s
CI / image-build (push) Successful in 26s
CI / deploy-smoke (same-origin) (push) Successful in 40s
CI / deploy-smoke (split) (push) Successful in 2m13s
CI / package-publication (push) Successful in 51s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/90
Reviewed-by: The_River <[email protected]>
2026-10-10 16:32:32 +00:00
ekklesia 40ef0ff802 fix(deploy): bound object storage allocation size
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 50s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 2m19s
CI / package-publication (pull_request) Successful in 52s
2026-10-10 11:07:53 -05:00
ekklesia a0e9948820 fix(storage): recognize errors across module packages
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 29s
CI / migration-smoke (pull_request) Successful in 1m44s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 53s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / deploy-smoke (split) (pull_request) Successful in 2m20s
CI / package-publication (pull_request) Successful in 51s
2026-10-10 10:54:13 -05:00
ekklesia 6e64b3c5f9 test(deploy): allow slower display uploads
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m57s
CI / package-release (pull_request) Successful in 37s
CI / image-build (pull_request) Successful in 54s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 2m21s
CI / package-publication (pull_request) Successful in 52s
2026-10-09 22:37:18 -05:00
ekklesia fa27603133 test(deploy): exercise external module packages
CI / verify (pull_request) Successful in 1m13s
CI / infrastructure-smoke (pull_request) Successful in 40s
CI / migration-smoke (pull_request) Successful in 1m56s
CI / package-release (pull_request) Successful in 37s
CI / image-build (pull_request) Successful in 54s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 2m22s
CI / package-publication (pull_request) Successful in 55s
2026-10-09 22:18:04 -05:00
44 changed files with 1500 additions and 171 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:
+57 -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/api",
"version": "0.2.3",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
+7 -1
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";
@@ -407,6 +407,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) {
+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);
@@ -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);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/core",
"version": "0.2.3",
"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({
+12
View File
@@ -1,5 +1,6 @@
import {
EMPTY_MODULE_STORAGE,
isModuleStorageError,
isValidModuleStorageKey,
MODULE_STORAGE_KEY_MAX_BYTES,
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
}
});
it("recognizes a storage error from an installed module's separate package copy", () => {
const external = Object.assign(new Error("The storage service could not be reached."), {
code: "STORAGE_PROVIDER_UNAVAILABLE",
name: "ModuleStorageError",
});
expect(external).not.toBeInstanceOf(ModuleStorageError);
expect(isModuleStorageError(external)).toBe(true);
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
});
it("gives a module no way to reach another module's storage by knowing its name", async () => {
const shared = fakeProvider();
const announcements = createModuleStorage({
+3 -2
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
@@ -511,10 +512,10 @@ finds its files:
```sh
docker build \
--file deploy/Dockerfile \
--build-arg VERSION=0.2.3 \
--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.3 \
--tag ekklesia:0.2.5 \
.
```
+4
View File
@@ -132,6 +132,10 @@ services:
command:
- mini
- -dir=/data
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
# volume size leaves modest hosts with room for only a few collections; after that, writes
# stall instead of failing. This changes allocation granularity, not object or total capacity.
- -master.volumeSizeLimitMB=32
- -s3.port.https=8443
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
- -s3.key.file=/run/ekklesia/tls/object-storage.key
+130
View File
@@ -0,0 +1,130 @@
# Setting up a display kiosk
A display is a screen — a television with a small computer, or a mini PC on a wall — that opens one
module's display page full screen and keeps it open unattended. This guide sets one up so it starts
on boot, stays paired, recovers from network and server outages on its own, and never opens a second
copy of itself.
Pairing and display identity belong to the platform (ADR 0036). Today an administrator approves a
screen in the Dashboard sidebar's **Display devices** panel; a later release moves this to
**Settings → Displays**. Nothing below changes when that happens.
## What the screen needs
- Chromium or Google Chrome, current stable. The display page is tested against Chromium.
- A **dedicated, persistent browser profile directory**. The device credential is an HttpOnly cookie
in that profile; it is what keeps the screen paired across reboots. Never use a guest, incognito, or
"clear data on exit" profile, and never share the profile with a person's browsing.
- Network access to the installation's **API hostname** (the display page, its realtime stream, and
its module requests) and to the storage hostname that serves images.
- A clock that is roughly right. Playback follows the server's clock, but TLS does not tolerate a
wildly wrong one.
## The address
Every display-capable module has one display page on the API hostname:
```text
https://<api-hostname>/display/<module-id>
```
For Signage that is `https://<api-hostname>/display/signage`. The Signage **Displays** tab shows the
exact address under **Copy setup address**. Open it on the screen itself; opening it on an
administrator's own computer only previews the display (and that browser appears in the roster as a
*Signed-in browser*, which can be removed once it is offline).
## Launching the browser
Run exactly **one** launcher, and launch the page as an app window with a fixed profile:
```bash
chromium \
--user-data-dir=/var/lib/display-kiosk/profile \
--app=https://<api-hostname>/display/signage \
--kiosk \
--no-first-run \
--noerrdialogs \
--disable-session-crashed-bubble \
--hide-crash-restore-bubble \
--disable-features=Translate \
--autoplay-policy=no-user-gesture-required \
--check-for-update-interval=31536000
```
- `--app` opens the page with no tab strip and no address bar. `--kiosk` makes it full screen.
- `--user-data-dir` is the persistent profile that holds the pairing. Keep it on persistent storage
and back it up with nothing: if it is lost, the screen simply shows a new pairing code.
- Do **not** add `--restore-last-session`, and do not also start the browser from a desktop autostart
entry. Chromium allows one process per profile; a second launch hands its URL to the running
process, which opens a **second window or tab** of the display. That is the usual cause of
"duplicate" displays. One launcher, one profile, one window.
### Start on boot and restart on exit (systemd)
Run the browser as an unprivileged user in that user's graphical session, from a single user unit:
```ini
# ~/.config/systemd/user/display-kiosk.service
[Unit]
Description=Display kiosk
After=graphical-session.target network-online.target
Wants=network-online.target
PartOf=graphical-session.target
[Service]
ExecStart=/usr/bin/chromium --user-data-dir=%h/.local/share/display-kiosk --app=https://<api-hostname>/display/signage --kiosk --no-first-run --noerrdialogs --disable-session-crashed-bubble --hide-crash-restore-bubble --disable-features=Translate --autoplay-policy=no-user-gesture-required --check-for-update-interval=31536000
Restart=always
RestartSec=5
[Install]
WantedBy=graphical-session.target
```
```bash
systemctl --user daemon-reload
systemctl --user enable --now display-kiosk.service
loginctl enable-linger <kiosk-user> # with the desktop set to log this user in automatically
```
`systemctl --user stop display-kiosk` closes the browser cleanly, which is what keeps its profile —
and the pairing cookie — intact. Avoid cutting power to a running kiosk when you can: a clean shutdown
is always safer for a browser profile.
## Pairing
1. Start the kiosk. With no credential it shows a **six-digit pairing code**.
2. In the Dashboard, enter the code under **Display devices**, give the screen a name, and approve.
3. Within a few seconds the screen collects its credential and starts showing content.
4. In Signage → **Displays**, name the display and choose its group and audience.
A paired screen stays paired across browser restarts and reboots, and for as long as it is in use:
its credential is renewed each time it checks in, and only expires after **180 days without being
used** (ADR 0056). A screen that is switched off and stored for longer than that, or that is revoked,
returns to its pairing code and is paired again the same way.
## What happens when things go wrong
| Event | What the screen does |
| --- | --- |
| Network drops for seconds | Nothing visible. The display keeps running; the realtime stream reconnects. |
| Network or API down for longer | Keeps playing what it has cached, with a small corner note; recovers by itself when the server is back. No remount, no reload. |
| API restarted or upgraded | Reconnects by itself. A new module version is picked up within five minutes and swapped in. |
| Browser crashes or is restarted | systemd restarts it; the same profile is still paired. |
| Machine reboots | Starts on boot and resumes. If the server is not up yet, the screen retries on its own (every few seconds, backing off to once a minute) until it is. |
| Display revoked in the Dashboard | Stops showing content within seconds and returns to its pairing code. |
| Display moved to another group or audience | Switches content within about one heartbeat (≈12 s), no reload. |
**Offline cold start is not supported yet.** A screen that is already showing content survives an
outage. A screen that *boots* while the server is completely unreachable can load its cached page but
cannot mount the module until the server answers, because the authorization it needs is never cached
(ADR 0043, Signage ADR 0006). It shows a short message and retries on its own until the server is
reachable — nobody needs to touch it.
## Checklist for a new kiosk
- [ ] One systemd unit, no desktop autostart entry, no second launcher.
- [ ] `--user-data-dir` on persistent storage, not shared, not cleared on exit.
- [ ] Automatic login of the kiosk user, and screen blanking/power saving disabled.
- [ ] Paired and named; assigned in Signage.
- [ ] Unplug the network for a minute: content keeps playing, then the note clears on reconnect.
- [ ] Reboot: the screen comes back paired without anyone touching it.
@@ -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.
+2 -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,4 @@ 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
+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`;
+1 -1
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:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ekklesia",
"version": "0.2.3",
"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.3",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+22
View File
@@ -87,6 +87,28 @@ export class ModuleStorageError extends Error {
}
}
/**
* Whether `value` is a module-storage error, judged by its stable public shape rather than its class.
*
* An installed module bundles its own copy of the contracts package (ADR 0049), so an error created
* by the host's storage provider does not share class identity with the module's
* `ModuleStorageError`. `instanceof ModuleStorageError` is therefore not a valid check across the
* module boundary.
*/
export function isModuleStorageError(
value: unknown,
): value is Pick<ModuleStorageError, "code" | "key" | "message" | "name"> {
if (!(value instanceof Error) || value.name !== "ModuleStorageError") {
return false;
}
const { code, key } = value as { code?: unknown; key?: unknown };
return (
typeof code === "string" &&
(MODULE_STORAGE_ERROR_CODES as readonly string[]).includes(code) &&
(key === undefined || typeof key === "string")
);
}
/** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes,
* not characters, because that is what the wire format bounds. */
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/design-tokens",
"version": "0.2.3",
"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.3",
"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.3",
"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.3",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+1
View File
@@ -37,6 +37,7 @@ export {
isDevicePrincipal,
isModuleHttpMethod,
isModuleRouteBodyMode,
isModuleStorageError,
isValidModuleStorageKey,
isValidModuleStorageReadUrlFilename,
MODULE_HTTP_BODY_MODES,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/module-ui-build",
"version": "0.2.3",
"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.3",
"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.3",
"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.3",
"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.3",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
+34 -16
View File
@@ -35,6 +35,10 @@ function required(name) {
return value;
}
function optional(name) {
return process.env[name] || "";
}
let failures = 0;
function check(condition, description) {
@@ -462,36 +466,50 @@ async function ownerCookie() {
}
/**
* The installed-module checks (ADR 0048): whether `example` is in the module inventory, and what
* its own route returns. Absent, it is in no inventory and its routes are not mounted at all;
* present, its records route returns exactly the expected values, which proves its tables, its
* dependencies, and the platform's database facade all work from module storage.
* The installed-module checks (ADR 0048): whether the selected module is in the UI inventory and,
* when one is supplied, whether its module-owned probe route responds. Core's own `example` fixture
* retains its stronger records assertion, which also proves its database facade.
*/
async function moduleChecks() {
const expectation = required("SMOKE_MODULE_EXPECT");
const moduleId = optional("SMOKE_MODULE_ID") || "example";
const probePath = optional("SMOKE_MODULE_PROBE_PATH");
const cookie = await ownerCookie();
const headers = { cookie, origin: dashboardOrigin };
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
const inventoryBody = await inventory.json();
const installed =
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === "example");
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === moduleId);
if (expectation === "absent") {
check(inventory.status === 200 && !installed, "the module inventory has no `example` module");
check(
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
"the absent module's routes are not mounted",
inventory.status === 200 && !installed,
`the module inventory has no \`${moduleId}\` module`,
);
} else {
const expected = JSON.parse(expectation);
check(installed, "the module inventory lists the `example` module");
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
check(
JSON.stringify(values) === JSON.stringify(expected),
`the module's records route returns ${JSON.stringify(expected)}`,
);
check(installed, `the module inventory lists the \`${moduleId}\` module`);
}
if (moduleId === "example") {
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
if (expectation === "absent") {
check(
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
"the absent module's routes are not mounted",
);
} else {
const expected = JSON.parse(expectation);
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
check(
JSON.stringify(values) === JSON.stringify(expected),
`the module's records route returns ${JSON.stringify(expected)}`,
);
}
} else if (expectation !== "absent" && probePath) {
const probe = await fetch(`${apiOrigin}${probePath}`, { headers });
await probe.arrayBuffer();
check(probe.status === 200, `the module probe ${probePath} responds`);
}
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
+69 -18
View File
@@ -14,7 +14,7 @@
// stream-failure the first realtime connection is refused: is realtime re-established?
// caching-probe can a display on the API origin store storage-origin images in Cache Storage?
// stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline
// revoke revoke the paired kiosk while its display is open
// revoke revoke the paired kiosk while its display is open, then pair it again
//
// Every DevTools command and every page-side request has its own deadline, and a step has an overall
// budget. A call that never answers fails the step with a line naming that exact call, because a call
@@ -426,23 +426,48 @@ async function seedSignage(owner) {
const base = "/api/v1/modules/signage";
const images = [];
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
const uploaded = await owner.evaluate(
`(async () => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 360;
const context = canvas.getContext("2d");
context.fillStyle = ${JSON.stringify(colour)};
context.fillRect(0, 0, 640, 360);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
const response = await fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
signal: AbortSignal.timeout(${requestTimeoutMs}),
});
return { status: response.status, body: await response.json().catch(() => null) };
})()`,
`uploading slide ${index}`,
);
let uploaded;
for (let attempt = 1; attempt <= 2; attempt += 1) {
uploaded = await owner.evaluate(
`(async () => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 360;
const context = canvas.getContext("2d");
context.fillStyle = ${JSON.stringify(colour)};
context.fillRect(0, 0, 640, 360);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
return fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
signal: AbortSignal.timeout(${requestTimeoutMs}),
}).then(
async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
);
})()`,
`uploading slide ${index}`,
);
if (
attempt === 1 &&
uploaded.status === 503 &&
uploaded.body?.code === "SIGNAGE_STORAGE_UNAVAILABLE"
) {
note(`slide ${index} met one transient storage outage; retrying once`);
await sleep(1_000);
continue;
}
break;
}
if (uploaded.status === "timeout") {
throw new HangError(`HTTP POST ${base}/images from page "owner"`, requestTimeoutMs);
}
if (uploaded.status !== 200 && uploaded.status !== 201) {
const code =
typeof uploaded.body?.code === "string" && /^SIGNAGE_[A-Z_]+$/u.test(uploaded.body.code)
? ` (${uploaded.body.code})`
: "";
throw new Error(`Uploading slide ${index} returned HTTP ${uploaded.status}${code}.`);
}
images.push(uploaded.body?.image?.id);
}
check(
@@ -951,6 +976,32 @@ async function run() {
result.stopped,
`a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`,
);
// Revocation is recoverable: the same screen pairs again and resumes, keeping its place in the
// module's roster. It also leaves the kiosk paired for the steps that run after this one.
const rosterBefore = moduleId === "signage" ? (await signageViewers(owner.page)).length : 0;
if (await page.waitFor(find("display-pairing-code"), "a fresh pairing code after revocation")) {
const code = (
await page.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code")
).replace(/\D/gu, "");
const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", {
code,
deviceName: "Repro kiosk",
});
check(approved.status === 200, "the owner approves the revoked screen's new code");
check(
await waitLive(page, "the re-paired display to go live"),
"a revoked screen is paired again and resumes without a reload",
);
if (moduleId === "signage") {
const rosterAfter = (await signageViewers(owner.page)).length;
check(
rosterAfter === rosterBefore,
`re-pairing keeps the screen's existing roster entry (${rosterBefore} before, ${rosterAfter} after)`,
);
}
} else {
check(false, "a revoked screen shows a fresh pairing code");
}
await page.close();
kiosk.close();
await finishOwner(owner);
+190 -49
View File
@@ -7,6 +7,8 @@
# bash scripts/smoke-deploy.sh builds the image first
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
# EKKLESIA_SMOKE_MODULE_RELEASE_DIR=<dir> EKKLESIA_SMOKE_MODULE_ID=<id> \
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
#
# Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile.
# It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the
@@ -15,10 +17,10 @@
# ADR 0048 end to end with that same image:
#
# - it boots with no modules;
# - the repository's module template is packaged by the canonical packager as an Ekklesia Module
# Package v1 (ADR 0049; scripts/lib/build-module-package.Dockerfile only provides the build
# environment), verified by the same image's own verifier, extracted to staging, installed into
# module storage, its migrations planned and explicitly applied, and the API restarted;
# - either the repository's module template is packaged by the canonical packager, or an external
# canonical release pair is supplied through EKKLESIA_SMOKE_MODULE_RELEASE_DIR; the package is
# verified by the same image's own verifier, extracted to staging, installed into module storage,
# its migrations planned and explicitly applied, and the API restarted;
# - a real headless Chromium drives the Dashboard, the module, its display, and sign-out;
# - the module is removed and the API restarted: Core still runs, migration planning reports the
# module's retained history, and its data survives;
@@ -83,6 +85,28 @@ same-origin | split) ;;
;;
esac
module_release_directory="${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"
module_id="${EKKLESIA_SMOKE_MODULE_ID:-example}"
module_probe_path="${EKKLESIA_SMOKE_MODULE_PROBE_PATH:-}"
[[ "$module_id" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]] || {
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_ID is not a valid module id." >&2
exit 1
}
if [[ -n "$module_release_directory" ]]; then
[[ "$topology" == split ]] || {
echo "smoke-deploy: an external module release requires the split topology." >&2
exit 1
}
[[ -d "$module_release_directory" ]] || {
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_RELEASE_DIR is not a directory." >&2
exit 1
}
fi
if [[ -n "$module_probe_path" && "$module_probe_path" != "/api/v1/modules/${module_id}/"* ]]; then
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_PROBE_PATH must be inside the selected module's API namespace." >&2
exit 1
fi
run_id="$(openssl rand -hex 4)"
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries
@@ -217,22 +241,31 @@ if [[ -z "$image" ]]; then
fi
if [[ "$topology" == split ]]; then
# The module template as a Module Package v1, from the canonical packager. Never a second platform
# image: the package is installed into module storage below, after the image verifies it.
step "Packaging the module template with the canonical Ekklesia packager"
source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/build-module-package.Dockerfile \
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
--output "type=local,dest=${work}/module-package" . >/dev/null ||
fail "the module package did not build."
# Never a second platform image: the package is installed into module storage below, after the
# generic image verifies it. The default keeps Core's own template smoke self-contained; downstream
# module CI hands this harness the exact release pair it built instead.
if [[ -z "$module_release_directory" ]]; then
step "Packaging the module template with the canonical Ekklesia packager"
source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/build-module-package.Dockerfile \
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
--output "type=local,dest=${work}/module-package" . >/dev/null ||
fail "the module package did not build."
package_output="$work/module-package"
else
step "Accepting the external module's canonical release pair"
package_output="$module_release_directory"
fi
# The archive and its digest are one release: copied together, from this run's own build output,
# into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is
# mode 0700 and owned by whoever built it, so it is never handed to the verifier itself.
package_archive="$(module_package_release "$work/module-package" "$work/package")" ||
package_archive="$(module_package_release "$package_output" "$work/package")" ||
fail "the packager did not produce exactly one archive and its digest."
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
fail "the packager produced an unexpected archive name."
if [[ -z "$module_release_directory" ]]; then
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
fail "the packager produced an unexpected archive name."
fi
echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))"
fi
@@ -453,18 +486,41 @@ install_test_module() {
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
-v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
--sha256-file "/package/${package_archive}.sha256" --extract-to /staging/example 2>&1
--sha256-file "/package/${package_archive}.sha256" --extract-to "/staging/${module_id}" 2>&1
)" || fail "the module package did not verify: ${verified}"
echo "ok ${verified%%$'\n'*}"
docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
-v "$daemon_work/staging:/staging" "$postgres_image" -ec '
rm -rf /modules/.incoming-example && cp -R /staging/example /modules/.incoming-example &&
rm -rf /staging/example && chown -R 0:0 /modules/.incoming-example &&
chmod -R a+rX,go-w /modules/.incoming-example &&
mv /modules/.incoming-example /modules/example' ||
-v "$daemon_work/staging:/staging" -e MODULE_ID="$module_id" "$postgres_image" -ec '
incoming="/modules/.incoming-${MODULE_ID}"
installed="/modules/${MODULE_ID}"
staged="/staging/${MODULE_ID}"
rm -rf "$incoming" && cp -R "$staged" "$incoming" && rm -rf "$staged" &&
chown -R 0:0 "$incoming" && chmod -R a+rX,go-w "$incoming" && mv "$incoming" "$installed"' ||
fail "the verified module could not be installed into module storage."
}
# The package and its claimed digest are deliberately made to disagree. The verifier must reject it
# before extraction; the valid release pair is left untouched for the installation that follows.
reject_invalid_package() {
local rejected zero_digest
zero_digest="$(printf '0%.0s' {1..64})"
if rejected="$(
docker run --rm --network none --read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
--cap-drop ALL --security-opt no-new-privileges \
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
-v "$daemon_work/package:/package:ro" \
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
--sha256 "$zero_digest" 2>&1
)"; then
fail "the verifier accepted a package whose archive did not match its claimed digest."
fi
grep -q '^\[MODULE_PACKAGE_DIGEST_MISMATCH\]' <<<"$rejected" ||
fail "the invalid package was rejected for an unexpected reason."
echo "ok an invalid package is rejected before installation"
}
# restart_api <command...>: stops the API, runs the command, and starts the API again. A controlled
# restart is the only way a module change is picked up; nothing is ever hot loaded.
restart_api() {
@@ -476,9 +532,21 @@ restart_api() {
fi
}
# recreate_api removes the application container, not its installation-owned module bind mount or
# database volumes, then creates a fresh container from the same generic image.
recreate_api() {
"${compose[@]}" stop api >/dev/null 2>&1 || fail "the API did not stop for recreation."
"${compose[@]}" rm --force api >/dev/null 2>&1 || fail "the API container was not removed."
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 api 2>&1)"; then
compose_report_not_ready "$up_output" "${compose[@]}"
fail "the recreated API did not become healthy."
fi
}
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
module_check() {
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
-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"
}
@@ -491,21 +559,36 @@ if [[ "$topology" == split ]]; then
step "Booting the generic image with empty module storage"
module_check absent || fail "the image without modules did not run module-free."
step "Rejecting an invalid package before installation"
reject_invalid_package
step "Installing the test module into module storage, migrating explicitly, and restarting the API"
install_module_and_migrate() {
install_test_module
local plan
plan="$(plan_output)"
grep -q '^- \[pending\] example:0000_initial ' <<<"$plan" ||
grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan" ||
fail "the plan does not list the installed module's migration as pending."
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "applying the installed module's migration failed."
plan="$(plan_output)"
grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
fail "the plan does not list the installed module's migration as applied."
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
fail "the installed module still has a pending migration after apply."
fi
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "reapplying migrations was not idempotent."
}
restart_api install_module_and_migrate
module_check '[]' || fail "the installed module did not run from module storage."
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
>/dev/null || fail "could not write the module's own data."
module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
if [[ "$module_id" == example ]]; then
module_check '[]' || fail "the installed module did not run from module storage."
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
>/dev/null || fail "could not write the module's own data."
module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
else
module_check present || fail "the installed module did not run from module storage."
fi
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
@@ -516,24 +599,31 @@ if [[ "$topology" == split ]]; then
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
browser_container="${project}-browser"
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
"$browser_image" \
"--ignore-certificate-errors-spki-list=${spki}" \
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
start_owner_browser() {
docker rm --force "$browser_container" >/dev/null 2>&1 || true
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
"$browser_image" \
"--ignore-certificate-errors-spki-list=${spki}" \
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
}
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
stage browser.mjs
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
--label "${EKKLESIA_CI_LABEL}=${project}" \
--network "$edge_network" \
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed."
run_browser_checks() {
start_owner_browser
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
--label "${EKKLESIA_CI_LABEL}=${project}" \
--network "$edge_network" \
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
--entrypoint node "$image" /smoke/browser.mjs
}
run_browser_checks || fail "browser checks failed."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
# A kiosk: a second browser whose profile directory is a volume, so it outlives the container
@@ -569,6 +659,7 @@ if [[ "$topology" == split ]]; then
--network "$edge_network" \
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
-e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \
-e SMOKE_CALL_TIMEOUT_MS="${EKKLESIA_SMOKE_CALL_TIMEOUT_MS:-30000}" \
-e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
-e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \
@@ -610,29 +701,79 @@ if [[ "$topology" == split ]]; then
((display_status == 0)) || fail "display-device checks failed."
fi
step "Restarting the API process with the installed module and data intact"
restart_api
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the module or its data did not survive an API restart."
else
module_check present || fail "the module did not survive an API restart."
fi
run_browser_checks || fail "the module UI did not survive an API restart."
step "Recreating the API container with the same module storage and database volumes"
recreate_api
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the module or its data did not survive container recreation."
else
module_check present || fail "the module did not survive container recreation."
fi
plan="$(plan_output)"
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
fail "container recreation made an applied module migration pending again."
fi
run_browser_checks || fail "the module UI did not survive container recreation."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
display_driver 120 restart || fail "the display runtime or its data did not survive container recreation."
fi
step "Disabling and re-enabling the module through the supported lifecycle command"
restart_api "${compose[@]}" run --rm --no-deps migrate node \
core/dist/cli/set-module-enabled.js --disable --module "$module_id"
module_check absent || fail "the disabled module was still reported as running."
module_storage "test -d /modules/${module_id}" ||
fail "disabling the module removed its installed package."
restart_api "${compose[@]}" run --rm --no-deps migrate node \
core/dist/cli/set-module-enabled.js --enable --module "$module_id"
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the re-enabled module did not return with its data."
else
module_check present || fail "the re-enabled module did not return."
fi
run_browser_checks || fail "the re-enabled module UI did not return."
step "Removing the module from module storage and restarting: Core runs, its data stays"
restart_api module_storage 'rm -rf /modules/example'
restart_api module_storage "rm -rf /modules/${module_id}"
module_check absent || fail "the removed module is still running."
plan="$(plan_output)"
grep -q '^- \[retained\] example: 1 applied migration kept; the module is not installed$' \
grep -q "^- \\[retained\\] ${module_id}: .*applied migration.*kept; the module is not installed$" \
<<<"$plan" || fail "migration planning did not report the removed module's retained history."
grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" ||
fail "migration planning did not succeed cleanly with the module absent."
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "applying migrations failed with the module absent."
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
fail "the removed module's data did not survive."
if [[ "$module_id" == example ]]; then
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
fail "the removed module's data did not survive."
fi
echo "ok the module is gone, Core runs, its history is retained, and its data survives"
step "Reinstalling the exact same package and restarting: the module returns with its previous data"
restart_api install_test_module
plan="$(plan_output)"
grep -q '^- \[applied\] example:0000_initial ' <<<"$plan" ||
grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
fail "the restored module's migration history is not valid."
if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then
fail "the restored module left retained or pending migrations."
fi
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
else
module_check present || fail "the restored module did not return."
fi
run_browser_checks || fail "the restored module UI did not return."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
display_driver 120 restart || fail "the restored display runtime did not return with its data."
fi
echo "ok the restored module runs again, with its history strict and its data intact"
fi
+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"}}' |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/shell",
"version": "0.2.3",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
+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>
);
}
+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 } : {}),
}),
+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)");
});
});
+301
View File
@@ -180,6 +180,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 +193,7 @@ async function render(
config={config}
{...(connectivity ? { connectivity } : {})}
{...(importModule ? { importModule } : {})}
{...timing}
moduleId={moduleId}
/>,
);
@@ -926,3 +933,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);
});
});
+35 -6
View File
@@ -179,7 +179,7 @@ describe("CI scripts on a shared Docker daemon", () => {
expect(packaging).not.toMatch(/pnpm[^\n]*\bdeploy\b/u);
expect(deploy).toContain("scripts/lib/build-module-package.Dockerfile");
expect(deploy).toMatch(
/--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to \/staging\/example/u,
/--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to "\/staging\/\$\{module_id\}"/u,
);
expect(deploy).toContain("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m");
expect(deploy).toContain(
@@ -187,16 +187,20 @@ describe("CI scripts on a shared Docker daemon", () => {
);
expect(deploy).toContain("-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier");
// Module storage only ever receives the verified tree from staging.
expect(deploy).toContain("cp -R /staging/example /modules/.incoming-example");
expect(deploy).toContain(`staged="/staging/\${MODULE_ID}"`);
expect(deploy).toContain('cp -R "$staged" "$incoming"');
expect(deploy).toContain('mv "$incoming" "$installed"');
});
it("hand the verifier the package's archive and digest together, from a fresh release pair, never from build output", async () => {
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
expect(deploy).toContain('source "$repository_root/scripts/lib/module-package-release.sh"');
// The pair is made from this run's own build output, into a directory that must not exist yet.
expect(deploy).toContain('--output "type=local,dest=${work}/module-package"');
expect(deploy).toContain(`--output "type=local,dest=\${work}/module-package"`);
expect(deploy).toContain('package_output="$work/module-package"');
expect(deploy).toContain('package_output="$module_release_directory"');
expect(deploy).toContain(
'package_archive="$(module_package_release "$work/module-package" "$work/package")"',
'package_archive="$(module_package_release "$package_output" "$work/package")"',
);
expect(deploy).toMatch(/^\s*stage package$/mu);
// BuildKit's output directory is 0700 and owned by whoever built it: it is never mounted.
@@ -207,11 +211,36 @@ describe("CI scripts on a shared Docker daemon", () => {
.split("\n")
.find((line) => line.includes("core/dist/cli/verify-module-package.js"));
expect(verifier).toContain('-v "$daemon_work/package:/package:ro"');
expect(verifier).toContain('"/package/${package_archive}"');
expect(verifier).toContain('--sha256-file "/package/${package_archive}.sha256"');
expect(verifier).toContain(`"/package/\${package_archive}"`);
expect(verifier).toContain(`--sha256-file "/package/\${package_archive}.sha256"`);
expect(verifier).not.toMatch(/--sha256\s/u);
});
it("accepts one exact external module release without putting its source in the platform image", async () => {
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).toContain(`module_release_directory="\${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"`);
expect(deploy).toContain(`module_id="\${EKKLESIA_SMOKE_MODULE_ID:-example}"`);
expect(deploy).toContain('package_output="$module_release_directory"');
expect(deploy).toContain('-e SMOKE_MODULE_ID="$module_id"');
expect(deploy).toContain(`--extract-to "/staging/\${module_id}"`);
expect(deploy).toContain(`installed="/modules/\${MODULE_ID}"`);
});
it("proves installed module state across restart, container recreation, and lifecycle changes", async () => {
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).toContain(`"\${compose[@]}" rm --force api`);
expect(deploy).toContain("container recreation made an applied module migration pending again");
expect(deploy).toContain('core/dist/cli/set-module-enabled.js --disable --module "$module_id"');
expect(deploy).toContain('core/dist/cli/set-module-enabled.js --enable --module "$module_id"');
expect(deploy).toContain("the restored display runtime did not return with its data");
});
it("gives page-side display requests enough time on a constrained shared runner", async () => {
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).toContain('-e SMOKE_CALL_TIMEOUT_MS="');
expect(deploy).toContain("EKKLESIA_SMOKE_CALL_TIMEOUT_MS:-30000");
});
it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => {
// ADR 0048: the split topology installs its test module into module storage at run time.
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
+1 -1
View File
@@ -138,7 +138,7 @@ describe("reference deployment compose files", () => {
]),
);
expect(services["object-storage"]?.command).toEqual(
expect.arrayContaining(["-s3.port.https=8443"]),
expect.arrayContaining(["-master.volumeSizeLimitMB=32", "-s3.port.https=8443"]),
);
const hba = (await text("postgres/pg_hba.conf"))
+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.3",
"@ekklesia/module-database": "^0.2.3",
"@ekklesia/module-sdk": "^0.2.3",
"@ekklesia/module-ui-sdk": "^0.2.3",
"@ekklesia/ui": "^0.2.3",
"@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.3",
"@ekklesia/module-ui-build": "^0.2.3",
"@ekklesia/typescript-config": "^0.2.3",
"@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",