Compare commits
12
Commits
v0.2.3
...
9331a8f7df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9331a8f7df | ||
|
|
8f90871be0 | ||
|
|
85b1377fd3 | ||
|
|
4bcdb578e6 | ||
|
|
06bad2fe36 | ||
|
|
ae976ca229 | ||
|
|
f9585cc7ec | ||
|
|
6dae29412f | ||
|
|
40ef0ff802 | ||
|
|
a0e9948820 | ||
|
|
6e64b3c5f9 | ||
|
|
fa27603133 |
@@ -6,27 +6,60 @@ contents, because restated details drift.
|
|||||||
|
|
||||||
## What this repository is
|
## What this repository is
|
||||||
|
|
||||||
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
|
Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
|
||||||
monorepo:
|
API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built
|
||||||
|
as a strictly layered pnpm/TypeScript monorepo:
|
||||||
|
|
||||||
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
||||||
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
|
execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
|
||||||
feature module, a surface, or an app.
|
imports a feature module, a surface, or an app.
|
||||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
- **Modules** are independently versioned product packages. First-party modules live in **their own
|
||||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
|
||||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
(ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
|
||||||
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
|
||||||
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
|
||||||
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
|
||||||
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a
|
||||||
|
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
|
||||||
|
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
|
||||||
|
Module Database ABI v1 (ADR 0050).
|
||||||
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||||
presentation concern a module does not. They consume public API/client contracts only, never Core
|
presentation concern a module does not. The shell **hosts** module UI through the generic loader;
|
||||||
or module internals directly.
|
it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only.
|
||||||
|
|
||||||
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
||||||
documented. Read `docs/architecture/repository-boundaries.md` and
|
documented. Read `docs/architecture/repository-boundaries.md` and
|
||||||
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
||||||
|
|
||||||
|
## Product direction lives beside this repository
|
||||||
|
|
||||||
|
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
|
||||||
|
parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard
|
||||||
|
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into
|
||||||
|
this repository to make them available. They are authoritative for product direction: read the
|
||||||
|
relevant stages and sections before designing a change. If this repository's documentation disagrees
|
||||||
|
with them, flag the conflict, correct the repository-local documentation where appropriate, and never
|
||||||
|
silently follow the stale text. Architectural decisions still change only through ADRs (below).
|
||||||
|
|
||||||
|
Rules from those documents that every change here must respect:
|
||||||
|
|
||||||
|
- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and
|
||||||
|
devices, accounts and identity, roles and authorization, branding, mail, push, storage providers,
|
||||||
|
and module installation/lifecycle — belong to the platform, and modules consume them.
|
||||||
|
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from
|
||||||
|
installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may.
|
||||||
|
Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy.
|
||||||
|
- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light,
|
||||||
|
dark, and system modes. Normal UI colours come only from the shared design tokens
|
||||||
|
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
|
||||||
|
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
|
||||||
|
in product UI. Destructive actions use in-product confirmation proportional to their impact.
|
||||||
|
- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard
|
||||||
|
sidebar's *Display devices* panel) and Signage's own display roster are a transitional
|
||||||
|
implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not
|
||||||
|
build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell
|
||||||
|
redesign early, and keep existing paired displays working across any change.
|
||||||
|
|
||||||
## ADRs are authoritative
|
## ADRs are authoritative
|
||||||
|
|
||||||
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
||||||
@@ -43,6 +76,7 @@ At minimum, read the current versions of:
|
|||||||
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
||||||
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
||||||
- `README.md` (current platform status — what is and is not implemented)
|
- `README.md` (current platform status — what is and is not implemented)
|
||||||
|
- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above)
|
||||||
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
||||||
- `CONTRIBUTING.md`
|
- `CONTRIBUTING.md`
|
||||||
|
|
||||||
@@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
|
|||||||
|
|
||||||
## Working conventions
|
## Working conventions
|
||||||
|
|
||||||
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
|
- The canonical repositories are on the church Gitea under the `Zayin` organization:
|
||||||
change branch-protection settings.
|
`ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and
|
||||||
|
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
|
||||||
|
and branch from `origin/main`, never from whatever is checked out.
|
||||||
|
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
|
||||||
|
merge your own PR, and never change branch-protection settings.
|
||||||
|
- Never create or push a release tag. Release tags belong to the human maintainer.
|
||||||
|
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
|
||||||
|
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
|
||||||
|
platform logic into a module to avoid making the correct Core change.
|
||||||
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
||||||
burying them in an implementation.
|
burying them in an implementation.
|
||||||
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
EMPTY_MODULE_STORAGE,
|
EMPTY_MODULE_STORAGE,
|
||||||
|
isModuleStorageError,
|
||||||
isValidModuleStorageKey,
|
isValidModuleStorageKey,
|
||||||
MODULE_STORAGE_KEY_MAX_BYTES,
|
MODULE_STORAGE_KEY_MAX_BYTES,
|
||||||
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
|
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
|
||||||
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recognizes a storage error from an installed module's separate package copy", () => {
|
||||||
|
const external = Object.assign(new Error("The storage service could not be reached."), {
|
||||||
|
code: "STORAGE_PROVIDER_UNAVAILABLE",
|
||||||
|
name: "ModuleStorageError",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(external).not.toBeInstanceOf(ModuleStorageError);
|
||||||
|
expect(isModuleStorageError(external)).toBe(true);
|
||||||
|
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("gives a module no way to reach another module's storage by knowing its name", async () => {
|
it("gives a module no way to reach another module's storage by knowing its name", async () => {
|
||||||
const shared = fakeProvider();
|
const shared = fakeProvider();
|
||||||
const announcements = createModuleStorage({
|
const announcements = createModuleStorage({
|
||||||
|
|||||||
+3
-2
@@ -17,6 +17,7 @@ specific to one organization.
|
|||||||
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
|
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
|
||||||
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
|
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
|
||||||
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
|
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
|
||||||
|
| [`display-kiosk.md`](display-kiosk.md) | Setting up an unattended display screen: launch, pairing, and recovery. |
|
||||||
|
|
||||||
## The reference deployment
|
## The reference deployment
|
||||||
|
|
||||||
@@ -511,10 +512,10 @@ finds its files:
|
|||||||
```sh
|
```sh
|
||||||
docker build \
|
docker build \
|
||||||
--file deploy/Dockerfile \
|
--file deploy/Dockerfile \
|
||||||
--build-arg VERSION=0.2.3 \
|
--build-arg VERSION=0.2.4 \
|
||||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||||
--tag ekklesia:0.2.3 \
|
--tag ekklesia:0.2.4 \
|
||||||
.
|
.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ services:
|
|||||||
command:
|
command:
|
||||||
- mini
|
- mini
|
||||||
- -dir=/data
|
- -dir=/data
|
||||||
|
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
|
||||||
|
# volume size leaves modest hosts with room for only a few collections; after that, writes
|
||||||
|
# stall instead of failing. This changes allocation granularity, not object or total capacity.
|
||||||
|
- -master.volumeSizeLimitMB=32
|
||||||
- -s3.port.https=8443
|
- -s3.port.https=8443
|
||||||
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
||||||
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# 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. The credential lasts **180 days**
|
||||||
|
from pairing and is not renewed automatically (ADR 0036); after that the screen returns to its pairing
|
||||||
|
code and is paired again the same way. Note the pairing date of each screen.
|
||||||
|
|
||||||
|
## 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; pairing date recorded.
|
||||||
|
- [ ] 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.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ekklesia",
|
"name": "ekklesia",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/contracts",
|
"name": "@ekklesia/contracts",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -87,6 +87,28 @@ export class ModuleStorageError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `value` is a module-storage error, judged by its stable public shape rather than its class.
|
||||||
|
*
|
||||||
|
* An installed module bundles its own copy of the contracts package (ADR 0049), so an error created
|
||||||
|
* by the host's storage provider does not share class identity with the module's
|
||||||
|
* `ModuleStorageError`. `instanceof ModuleStorageError` is therefore not a valid check across the
|
||||||
|
* module boundary.
|
||||||
|
*/
|
||||||
|
export function isModuleStorageError(
|
||||||
|
value: unknown,
|
||||||
|
): value is Pick<ModuleStorageError, "code" | "key" | "message" | "name"> {
|
||||||
|
if (!(value instanceof Error) || value.name !== "ModuleStorageError") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { code, key } = value as { code?: unknown; key?: unknown };
|
||||||
|
return (
|
||||||
|
typeof code === "string" &&
|
||||||
|
(MODULE_STORAGE_ERROR_CODES as readonly string[]).includes(code) &&
|
||||||
|
(key === undefined || typeof key === "string")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes,
|
/** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes,
|
||||||
* not characters, because that is what the wire format bounds. */
|
* not characters, because that is what the wire format bounds. */
|
||||||
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
|
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/design-tokens",
|
"name": "@ekklesia/design-tokens",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-database",
|
"name": "@ekklesia/module-database",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-pack",
|
"name": "@ekklesia/module-pack",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-sdk",
|
"name": "@ekklesia/module-sdk",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export {
|
|||||||
isDevicePrincipal,
|
isDevicePrincipal,
|
||||||
isModuleHttpMethod,
|
isModuleHttpMethod,
|
||||||
isModuleRouteBodyMode,
|
isModuleRouteBodyMode,
|
||||||
|
isModuleStorageError,
|
||||||
isValidModuleStorageKey,
|
isValidModuleStorageKey,
|
||||||
isValidModuleStorageReadUrlFilename,
|
isValidModuleStorageReadUrlFilename,
|
||||||
MODULE_HTTP_BODY_MODES,
|
MODULE_HTTP_BODY_MODES,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-build",
|
"name": "@ekklesia/module-ui-build",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-host",
|
"name": "@ekklesia/module-ui-host",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-sdk",
|
"name": "@ekklesia/module-ui-sdk",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/typescript-config",
|
"name": "@ekklesia/typescript-config",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/ui",
|
"name": "@ekklesia/ui",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ function required(name) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optional(name) {
|
||||||
|
return process.env[name] || "";
|
||||||
|
}
|
||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
|
|
||||||
function check(condition, description) {
|
function check(condition, description) {
|
||||||
@@ -462,37 +466,51 @@ async function ownerCookie() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The installed-module checks (ADR 0048): whether `example` is in the module inventory, and what
|
* The installed-module checks (ADR 0048): whether the selected module is in the UI inventory and,
|
||||||
* its own route returns. Absent, it is in no inventory and its routes are not mounted at all;
|
* when one is supplied, whether its module-owned probe route responds. Core's own `example` fixture
|
||||||
* present, its records route returns exactly the expected values, which proves its tables, its
|
* retains its stronger records assertion, which also proves its database facade.
|
||||||
* dependencies, and the platform's database facade all work from module storage.
|
|
||||||
*/
|
*/
|
||||||
async function moduleChecks() {
|
async function moduleChecks() {
|
||||||
const expectation = required("SMOKE_MODULE_EXPECT");
|
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 cookie = await ownerCookie();
|
||||||
const headers = { cookie, origin: dashboardOrigin };
|
const headers = { cookie, origin: dashboardOrigin };
|
||||||
|
|
||||||
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
|
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
|
||||||
const inventoryBody = await inventory.json();
|
const inventoryBody = await inventory.json();
|
||||||
const installed =
|
const installed =
|
||||||
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === "example");
|
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === moduleId);
|
||||||
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
|
|
||||||
|
|
||||||
if (expectation === "absent") {
|
if (expectation === "absent") {
|
||||||
check(inventory.status === 200 && !installed, "the module inventory has no `example` module");
|
check(
|
||||||
|
inventory.status === 200 && !installed,
|
||||||
|
`the module inventory has no \`${moduleId}\` module`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
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(
|
check(
|
||||||
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
|
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
|
||||||
"the absent module's routes are not mounted",
|
"the absent module's routes are not mounted",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const expected = JSON.parse(expectation);
|
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;
|
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
|
||||||
check(
|
check(
|
||||||
JSON.stringify(values) === JSON.stringify(expected),
|
JSON.stringify(values) === JSON.stringify(expected),
|
||||||
`the module's records route returns ${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`, {
|
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
|
||||||
body: "{}",
|
body: "{}",
|
||||||
|
|||||||
@@ -426,7 +426,9 @@ async function seedSignage(owner) {
|
|||||||
const base = "/api/v1/modules/signage";
|
const base = "/api/v1/modules/signage";
|
||||||
const images = [];
|
const images = [];
|
||||||
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
|
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
|
||||||
const uploaded = await owner.evaluate(
|
let uploaded;
|
||||||
|
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||||
|
uploaded = await owner.evaluate(
|
||||||
`(async () => {
|
`(async () => {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = 640;
|
canvas.width = 640;
|
||||||
@@ -435,14 +437,37 @@ async function seedSignage(owner) {
|
|||||||
context.fillStyle = ${JSON.stringify(colour)};
|
context.fillStyle = ${JSON.stringify(colour)};
|
||||||
context.fillRect(0, 0, 640, 360);
|
context.fillRect(0, 0, 640, 360);
|
||||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
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}`)}, {
|
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",
|
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
|
||||||
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
||||||
});
|
}).then(
|
||||||
return { status: response.status, body: await response.json().catch(() => null) };
|
async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
|
||||||
|
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
|
||||||
|
);
|
||||||
})()`,
|
})()`,
|
||||||
`uploading slide ${index}`,
|
`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);
|
images.push(uploaded.body?.image?.id);
|
||||||
}
|
}
|
||||||
check(
|
check(
|
||||||
|
|||||||
+159
-18
@@ -7,6 +7,8 @@
|
|||||||
# bash scripts/smoke-deploy.sh builds the image first
|
# bash scripts/smoke-deploy.sh builds the image first
|
||||||
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
||||||
# EKKLESIA_SMOKE_TOPOLOGY=split 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.
|
# 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
|
# 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:
|
# ADR 0048 end to end with that same image:
|
||||||
#
|
#
|
||||||
# - it boots with no modules;
|
# - it boots with no modules;
|
||||||
# - the repository's module template is packaged by the canonical packager as an Ekklesia Module
|
# - either the repository's module template is packaged by the canonical packager, or an external
|
||||||
# Package v1 (ADR 0049; scripts/lib/build-module-package.Dockerfile only provides the build
|
# canonical release pair is supplied through EKKLESIA_SMOKE_MODULE_RELEASE_DIR; the package is
|
||||||
# environment), verified by the same image's own verifier, extracted to staging, installed into
|
# verified by the same image's own verifier, extracted to staging, installed into module storage,
|
||||||
# module storage, its migrations planned and explicitly applied, and the API restarted;
|
# its migrations planned and explicitly applied, and the API restarted;
|
||||||
# - a real headless Chromium drives the Dashboard, the module, its display, and sign-out;
|
# - 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
|
# - the module is removed and the API restarted: Core still runs, migration planning reports the
|
||||||
# module's retained history, and its data survives;
|
# module's retained history, and its data survives;
|
||||||
@@ -83,6 +85,28 @@ same-origin | split) ;;
|
|||||||
;;
|
;;
|
||||||
esac
|
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)"
|
run_id="$(openssl rand -hex 4)"
|
||||||
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
|
# 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
|
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries
|
||||||
@@ -217,8 +241,10 @@ if [[ -z "$image" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$topology" == split ]]; then
|
if [[ "$topology" == split ]]; then
|
||||||
# The module template as a Module Package v1, from the canonical packager. Never a second platform
|
# Never a second platform image: the package is installed into module storage below, after the
|
||||||
# image: the package is installed into module storage below, after the image verifies it.
|
# 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"
|
step "Packaging the module template with the canonical Ekklesia packager"
|
||||||
source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
|
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."
|
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
|
||||||
@@ -226,13 +252,20 @@ if [[ "$topology" == split ]]; then
|
|||||||
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
|
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
|
||||||
--output "type=local,dest=${work}/module-package" . >/dev/null ||
|
--output "type=local,dest=${work}/module-package" . >/dev/null ||
|
||||||
fail "the module package did not build."
|
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,
|
# 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
|
# 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.
|
# 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."
|
fail "the packager did not produce exactly one archive and its digest."
|
||||||
|
if [[ -z "$module_release_directory" ]]; then
|
||||||
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
|
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
|
||||||
fail "the packager produced an unexpected archive name."
|
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"))"
|
echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -453,18 +486,41 @@ install_test_module() {
|
|||||||
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||||
-v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \
|
-v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \
|
||||||
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
|
--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}"
|
)" || fail "the module package did not verify: ${verified}"
|
||||||
echo "ok ${verified%%$'\n'*}"
|
echo "ok ${verified%%$'\n'*}"
|
||||||
docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
|
docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
|
||||||
-v "$daemon_work/staging:/staging" "$postgres_image" -ec '
|
-v "$daemon_work/staging:/staging" -e MODULE_ID="$module_id" "$postgres_image" -ec '
|
||||||
rm -rf /modules/.incoming-example && cp -R /staging/example /modules/.incoming-example &&
|
incoming="/modules/.incoming-${MODULE_ID}"
|
||||||
rm -rf /staging/example && chown -R 0:0 /modules/.incoming-example &&
|
installed="/modules/${MODULE_ID}"
|
||||||
chmod -R a+rX,go-w /modules/.incoming-example &&
|
staged="/staging/${MODULE_ID}"
|
||||||
mv /modules/.incoming-example /modules/example' ||
|
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."
|
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_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 is the only way a module change is picked up; nothing is ever hot loaded.
|
||||||
restart_api() {
|
restart_api() {
|
||||||
@@ -476,9 +532,21 @@ restart_api() {
|
|||||||
fi
|
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 <expectation>: "absent", or the exact JSON array of values `example` must return.
|
||||||
module_check() {
|
module_check() {
|
||||||
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
|
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"
|
-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"
|
step "Booting the generic image with empty module storage"
|
||||||
module_check absent || fail "the image without modules did not run module-free."
|
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"
|
step "Installing the test module into module storage, migrating explicitly, and restarting the API"
|
||||||
install_module_and_migrate() {
|
install_module_and_migrate() {
|
||||||
install_test_module
|
install_test_module
|
||||||
local plan
|
local plan
|
||||||
plan="$(plan_output)"
|
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."
|
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 ||
|
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||||
fail "applying the installed module's migration failed."
|
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
|
restart_api install_module_and_migrate
|
||||||
|
if [[ "$module_id" == example ]]; then
|
||||||
module_check '[]' || fail "the installed module did not run from module storage."
|
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')" \
|
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."
|
>/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."
|
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"
|
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,
|
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
|
||||||
@@ -516,6 +599,8 @@ if [[ "$topology" == split ]]; then
|
|||||||
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
|
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
|
||||||
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
|
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
|
||||||
browser_container="${project}-browser"
|
browser_container="${project}-browser"
|
||||||
|
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
|
# 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.
|
# 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}" \
|
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
@@ -524,8 +609,11 @@ if [[ "$topology" == split ]]; then
|
|||||||
"$browser_image" \
|
"$browser_image" \
|
||||||
"--ignore-certificate-errors-spki-list=${spki}" \
|
"--ignore-certificate-errors-spki-list=${spki}" \
|
||||||
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
|
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
|
||||||
|
}
|
||||||
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
|
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
|
||||||
stage browser.mjs
|
stage browser.mjs
|
||||||
|
run_browser_checks() {
|
||||||
|
start_owner_browser
|
||||||
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||||
--label "${EKKLESIA_CI_LABEL}=${project}" \
|
--label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
--network "$edge_network" \
|
--network "$edge_network" \
|
||||||
@@ -533,7 +621,9 @@ if [[ "$topology" == split ]]; then
|
|||||||
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
||||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
|
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
|
||||||
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
|
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
|
||||||
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed."
|
--entrypoint node "$image" /smoke/browser.mjs
|
||||||
|
}
|
||||||
|
run_browser_checks || fail "browser checks failed."
|
||||||
|
|
||||||
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
||||||
# A kiosk: a second browser whose profile directory is a volume, so it outlives the container
|
# 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" \
|
--network "$edge_network" \
|
||||||
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
||||||
-e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_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_STEP_BUDGET_MS="$((budget * 1000))" \
|
||||||
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
|
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
|
||||||
-e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \
|
-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."
|
((display_status == 0)) || fail "display-device checks failed."
|
||||||
fi
|
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"
|
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."
|
module_check absent || fail "the removed module is still running."
|
||||||
plan="$(plan_output)"
|
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."
|
<<<"$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" ||
|
grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" ||
|
||||||
fail "migration planning did not succeed cleanly with the module absent."
|
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 ||
|
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||||
fail "applying migrations failed with the module absent."
|
fail "applying migrations failed with the module absent."
|
||||||
|
if [[ "$module_id" == example ]]; then
|
||||||
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
|
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
|
||||||
fail "the removed module's data did not survive."
|
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"
|
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"
|
step "Reinstalling the exact same package and restarting: the module returns with its previous data"
|
||||||
restart_api install_test_module
|
restart_api install_test_module
|
||||||
plan="$(plan_output)"
|
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."
|
fail "the restored module's migration history is not valid."
|
||||||
if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then
|
if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then
|
||||||
fail "the restored module left retained or pending migrations."
|
fail "the restored module left retained or pending migrations."
|
||||||
fi
|
fi
|
||||||
|
if [[ "$module_id" == example ]]; then
|
||||||
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
|
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"
|
echo "ok the restored module runs again, with its history strict and its data intact"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/shell",
|
"name": "@ekklesia/shell",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { designToken } from "@ekklesia/design-tokens";
|
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 { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
approveDevicePairing,
|
approveDevicePairing,
|
||||||
@@ -61,6 +61,10 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
|||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [pairing, setPairing] = useState<PairingStep>({ step: "idle" });
|
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> => {
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -106,13 +110,15 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const revoke = async (deviceId: string): Promise<void> => {
|
const revoke = async (deviceId: string): Promise<void> => {
|
||||||
|
setRevoking(true);
|
||||||
try {
|
try {
|
||||||
await revokeHostDevice(config, deviceId);
|
await revokeHostDevice(config, deviceId);
|
||||||
await refresh();
|
|
||||||
} catch {
|
} catch {
|
||||||
// Left to the next refresh rather than asserting a failure the operator cannot act on.
|
// Left to the next refresh rather than asserting a failure the operator cannot act on.
|
||||||
}
|
}
|
||||||
await refresh();
|
await refresh();
|
||||||
|
setRevoking(false);
|
||||||
|
setConfirming(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -195,7 +201,7 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
|||||||
{device.revoked ? undefined : (
|
{device.revoked ? undefined : (
|
||||||
<Button
|
<Button
|
||||||
data-testid={`device-revoke-${device.id}`}
|
data-testid={`device-revoke-${device.id}`}
|
||||||
onClick={() => void revoke(device.id)}
|
onClick={() => setConfirming(device)}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
Revoke
|
Revoke
|
||||||
@@ -203,6 +209,38 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { designToken } from "@ekklesia/design-tokens";
|
import { designToken } from "@ekklesia/design-tokens";
|
||||||
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
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 {
|
import {
|
||||||
bootstrapDisplayHost,
|
bootstrapDisplayHost,
|
||||||
type DisplayHostBootstrap,
|
type DisplayHostBootstrap,
|
||||||
@@ -17,7 +17,13 @@ import { PairingScreen } from "./PairingScreen.js";
|
|||||||
|
|
||||||
type DisplayState =
|
type DisplayState =
|
||||||
| { readonly status: "loading"; readonly generation: number }
|
| { 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. */
|
/** No identity this display can use yet: show a pairing code rather than a human login form. */
|
||||||
| { readonly status: "pairing" }
|
| { readonly status: "pairing" }
|
||||||
| { readonly status: "unavailable" }
|
| { readonly status: "unavailable" }
|
||||||
@@ -31,6 +37,69 @@ export interface DisplayAppProps {
|
|||||||
/** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point
|
/** 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. */
|
* supplies the real ones; omitting it leaves a display that simply never reports being offline. */
|
||||||
readonly connectivity?: DisplayConnectivitySource;
|
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 = {
|
const displayStyles = {
|
||||||
@@ -86,11 +155,13 @@ const displayStyles = {
|
|||||||
* recovers on its own the moment connectivity returns.
|
* recovers on its own the moment connectivity returns.
|
||||||
*/
|
*/
|
||||||
function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement {
|
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
|
const label = props.connectivity.usingFallback
|
||||||
? props.connectivity.fallbackKind === "artifact"
|
? props.connectivity.fallbackKind === "artifact"
|
||||||
? "Offline — showing the last downloaded display code"
|
? "Offline — showing the last downloaded display code"
|
||||||
: "Offline — showing the last downloaded Ekklesia display"
|
: "Offline — showing the last downloaded display"
|
||||||
: "Offline — waiting for Ekklesia";
|
: "Offline — reconnecting";
|
||||||
return (
|
return (
|
||||||
<div data-testid="display-offline" role="status" style={displayStyles.offline}>
|
<div data-testid="display-offline" role="status" style={displayStyles.offline}>
|
||||||
{label}
|
{label}
|
||||||
@@ -130,7 +201,112 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
online: true,
|
online: true,
|
||||||
usingFallback: false,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!connectivitySource) {
|
if (!connectivitySource) {
|
||||||
@@ -142,27 +318,29 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
setConnectivity(next);
|
setConnectivity(next);
|
||||||
// Nobody presses Retry on a lobby television. When the browser says the network is back, the
|
// 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
|
// 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
|
// cached code after the server returns, since a fresh descriptor naming a new version replaces
|
||||||
// and the network-first worker fetches it (ADR 0043).
|
// 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) {
|
if (next.online) {
|
||||||
setAttempt((current) => current + 1);
|
revalidate("online");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
monitor.close();
|
monitor.close();
|
||||||
};
|
};
|
||||||
}, [connectivitySource]);
|
}, [connectivitySource, revalidate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
|
revalidation.current.inFlight?.abort();
|
||||||
setState({ generation: attempt, status: "loading" });
|
setState({ generation: attempt, status: "loading" });
|
||||||
void bootstrapDisplayHost(config, moduleId, abort.signal)
|
void bootstrapDisplayHost(config, moduleId, abort.signal)
|
||||||
.then((bootstrap) => {
|
.then((bootstrap) => {
|
||||||
if (abort.signal.aborted) {
|
if (abort.signal.aborted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState({ bootstrap, status: "ready" });
|
setState({ bootstrap, generation: attempt, status: "ready" });
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (abort.signal.aborted) {
|
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
|
// 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.
|
// paired yet, not an error worth logging on every wall in the building.
|
||||||
if (
|
if (isDenied(error)) {
|
||||||
error instanceof HostProtocolError &&
|
|
||||||
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
|
|
||||||
) {
|
|
||||||
setState({ status: "pairing" });
|
setState({ status: "pairing" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -188,12 +363,14 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
}, [attempt, config, moduleId]);
|
}, [attempt, config, moduleId]);
|
||||||
|
|
||||||
const retry = (): void => setAttempt((current) => current + 1);
|
const retry = (): void => setAttempt((current) => current + 1);
|
||||||
|
const showOffline = useSustained(
|
||||||
|
!connectivity.online || connectivity.usingFallback,
|
||||||
|
offlineIndicatorDelayMs,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="display-host" style={displayStyles.host}>
|
<div data-testid="display-host" style={displayStyles.host}>
|
||||||
{connectivity.online && !connectivity.usingFallback ? undefined : (
|
{showOffline ? <OfflineIndicator connectivity={connectivity} /> : undefined}
|
||||||
<OfflineIndicator connectivity={connectivity} />
|
|
||||||
)}
|
|
||||||
{state.status === "loading" ? (
|
{state.status === "loading" ? (
|
||||||
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
|
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
@@ -215,7 +392,8 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
config={config}
|
config={config}
|
||||||
descriptor={state.bootstrap.descriptor}
|
descriptor={state.bootstrap.descriptor}
|
||||||
{...(importModule ? { importModule } : {})}
|
{...(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}
|
principal={state.bootstrap.principal}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export interface DisplayUiFrameProps {
|
|||||||
readonly principal: ModuleUiPrincipal;
|
readonly principal: ModuleUiPrincipal;
|
||||||
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
|
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
|
||||||
readonly importModule?: ModuleUiImporter;
|
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 {
|
interface DisplayUiAttemptProps extends DisplayUiFrameProps {
|
||||||
@@ -94,7 +97,10 @@ const frameStyles = {
|
|||||||
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [status, setStatus] = useState<FrameStatus>("loading");
|
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(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -162,6 +168,11 @@ function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
|||||||
context: createModuleUiContext({
|
context: createModuleUiContext({
|
||||||
config,
|
config,
|
||||||
descriptor,
|
descriptor,
|
||||||
|
onAuthorizationResponse: (status) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
authorizationRef.current?.(status);
|
||||||
|
}
|
||||||
|
},
|
||||||
principal,
|
principal,
|
||||||
...(importModule ? { importModule } : {}),
|
...(importModule ? { importModule } : {}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -180,6 +180,12 @@ async function render(
|
|||||||
importModule?: (url: string) => Promise<unknown>,
|
importModule?: (url: string) => Promise<unknown>,
|
||||||
moduleId = "announcements",
|
moduleId = "announcements",
|
||||||
connectivity?: DisplayConnectivitySource,
|
connectivity?: DisplayConnectivitySource,
|
||||||
|
timing: {
|
||||||
|
readonly offlineIndicatorDelayMs?: number;
|
||||||
|
readonly revalidationIntervalMs?: number;
|
||||||
|
} = {
|
||||||
|
offlineIndicatorDelayMs: 0,
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
@@ -187,6 +193,7 @@ async function render(
|
|||||||
config={config}
|
config={config}
|
||||||
{...(connectivity ? { connectivity } : {})}
|
{...(connectivity ? { connectivity } : {})}
|
||||||
{...(importModule ? { importModule } : {})}
|
{...(importModule ? { importModule } : {})}
|
||||||
|
{...timing}
|
||||||
moduleId={moduleId}
|
moduleId={moduleId}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -926,3 +933,297 @@ describe("display host — offline behaviour", () => {
|
|||||||
expect(query("display-pairing")).not.toBeNull();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ describe("CI scripts on a shared Docker daemon", () => {
|
|||||||
expect(packaging).not.toMatch(/pnpm[^\n]*\bdeploy\b/u);
|
expect(packaging).not.toMatch(/pnpm[^\n]*\bdeploy\b/u);
|
||||||
expect(deploy).toContain("scripts/lib/build-module-package.Dockerfile");
|
expect(deploy).toContain("scripts/lib/build-module-package.Dockerfile");
|
||||||
expect(deploy).toMatch(
|
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("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m");
|
||||||
expect(deploy).toContain(
|
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");
|
expect(deploy).toContain("-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier");
|
||||||
// Module storage only ever receives the verified tree from staging.
|
// 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 () => {
|
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, " ");
|
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||||
expect(deploy).toContain('source "$repository_root/scripts/lib/module-package-release.sh"');
|
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.
|
// 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(
|
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);
|
expect(deploy).toMatch(/^\s*stage package$/mu);
|
||||||
// BuildKit's output directory is 0700 and owned by whoever built it: it is never mounted.
|
// 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")
|
.split("\n")
|
||||||
.find((line) => line.includes("core/dist/cli/verify-module-package.js"));
|
.find((line) => line.includes("core/dist/cli/verify-module-package.js"));
|
||||||
expect(verifier).toContain('-v "$daemon_work/package:/package:ro"');
|
expect(verifier).toContain('-v "$daemon_work/package:/package:ro"');
|
||||||
expect(verifier).toContain('"/package/${package_archive}"');
|
expect(verifier).toContain(`"/package/\${package_archive}"`);
|
||||||
expect(verifier).toContain('--sha256-file "/package/${package_archive}.sha256"');
|
expect(verifier).toContain(`--sha256-file "/package/\${package_archive}.sha256"`);
|
||||||
expect(verifier).not.toMatch(/--sha256\s/u);
|
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 () => {
|
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.
|
// 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, " ");
|
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ describe("reference deployment compose files", () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(services["object-storage"]?.command).toEqual(
|
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"))
|
const hba = (await text("postgres/pg_hba.conf"))
|
||||||
|
|||||||
+8
-8
@@ -13,18 +13,18 @@
|
|||||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ekklesia/design-tokens": "^0.2.3",
|
"@ekklesia/design-tokens": "^0.2.4",
|
||||||
"@ekklesia/module-database": "^0.2.3",
|
"@ekklesia/module-database": "^0.2.4",
|
||||||
"@ekklesia/module-sdk": "^0.2.3",
|
"@ekklesia/module-sdk": "^0.2.4",
|
||||||
"@ekklesia/module-ui-sdk": "^0.2.3",
|
"@ekklesia/module-ui-sdk": "^0.2.4",
|
||||||
"@ekklesia/ui": "^0.2.3",
|
"@ekklesia/ui": "^0.2.4",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ekklesia/module-pack": "0.2.3",
|
"@ekklesia/module-pack": "0.2.4",
|
||||||
"@ekklesia/module-ui-build": "^0.2.3",
|
"@ekklesia/module-ui-build": "^0.2.4",
|
||||||
"@ekklesia/typescript-config": "^0.2.3",
|
"@ekklesia/typescript-config": "^0.2.4",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.2.18",
|
||||||
"@types/react-dom": "19.2.4",
|
"@types/react-dom": "19.2.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user