Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba60f70835 | ||
|
|
9afe9376c4 | ||
|
|
44430b5d59 | ||
|
|
bef516796e | ||
|
|
9bef1d9ef2 | ||
|
|
f2fc069e66 | ||
|
|
0552442c7c | ||
|
|
63fda0f8d6 | ||
|
|
94fa7a17a8 | ||
|
|
437720f228 | ||
|
|
d3148d45f7 | ||
|
|
303d455315 | ||
|
|
39f48dc7c9 | ||
|
|
9d515cd56f | ||
|
|
b8a826f8be | ||
|
|
0c9b8f7af9 | ||
|
|
5e9433d96b | ||
|
|
650932a170 | ||
|
|
2d861c6bf0 | ||
|
|
bae4ff3c6a | ||
|
|
9331a8f7df | ||
|
|
8f90871be0 | ||
|
|
85b1377fd3 | ||
|
|
4bcdb578e6 | ||
|
|
06bad2fe36 | ||
|
|
ae976ca229 | ||
|
|
f9585cc7ec | ||
|
|
6dae29412f | ||
|
|
40ef0ff802 | ||
|
|
a0e9948820 | ||
|
|
6e64b3c5f9 | ||
|
|
fa27603133 |
@@ -27,7 +27,7 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
# The only registry-specific setting: where the train is published.
|
# The only registry-specific setting: where the train is published.
|
||||||
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
package-release:
|
package-release:
|
||||||
|
|||||||
@@ -6,27 +6,60 @@ contents, because restated details drift.
|
|||||||
|
|
||||||
## What this repository is
|
## What this repository is
|
||||||
|
|
||||||
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
|
Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
|
||||||
monorepo:
|
API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built
|
||||||
|
as a strictly layered pnpm/TypeScript monorepo:
|
||||||
|
|
||||||
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
||||||
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
|
execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
|
||||||
feature module, a surface, or an app.
|
imports a feature module, a surface, or an app.
|
||||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
- **Modules** are independently versioned product packages. First-party modules live in **their own
|
||||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
|
||||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
(ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
|
||||||
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
|
||||||
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
|
||||||
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
|
||||||
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a
|
||||||
|
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
|
||||||
|
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
|
||||||
|
Module Database ABI v1 (ADR 0050).
|
||||||
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||||
presentation concern a module does not. They consume public API/client contracts only, never Core
|
presentation concern a module does not. The shell **hosts** module UI through the generic loader;
|
||||||
or module internals directly.
|
it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only.
|
||||||
|
|
||||||
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
||||||
documented. Read `docs/architecture/repository-boundaries.md` and
|
documented. Read `docs/architecture/repository-boundaries.md` and
|
||||||
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
||||||
|
|
||||||
|
## Product direction lives beside this repository
|
||||||
|
|
||||||
|
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
|
||||||
|
parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard
|
||||||
|
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into
|
||||||
|
this repository to make them available. They are authoritative for product direction: read the
|
||||||
|
relevant stages and sections before designing a change. If this repository's documentation disagrees
|
||||||
|
with them, flag the conflict, correct the repository-local documentation where appropriate, and never
|
||||||
|
silently follow the stale text. Architectural decisions still change only through ADRs (below).
|
||||||
|
|
||||||
|
Rules from those documents that every change here must respect:
|
||||||
|
|
||||||
|
- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and
|
||||||
|
devices, accounts and identity, roles and authorization, branding, mail, push, storage providers,
|
||||||
|
and module installation/lifecycle — belong to the platform, and modules consume them.
|
||||||
|
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from
|
||||||
|
installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may.
|
||||||
|
Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy.
|
||||||
|
- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light,
|
||||||
|
dark, and system modes. Normal UI colours come only from the shared design tokens
|
||||||
|
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
|
||||||
|
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
|
||||||
|
in product UI. Destructive actions use in-product confirmation proportional to their impact.
|
||||||
|
- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard
|
||||||
|
sidebar's *Display devices* panel) and Signage's own display roster are a transitional
|
||||||
|
implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not
|
||||||
|
build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell
|
||||||
|
redesign early, and keep existing paired displays working across any change.
|
||||||
|
|
||||||
## ADRs are authoritative
|
## ADRs are authoritative
|
||||||
|
|
||||||
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
||||||
@@ -43,6 +76,7 @@ At minimum, read the current versions of:
|
|||||||
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
||||||
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
||||||
- `README.md` (current platform status — what is and is not implemented)
|
- `README.md` (current platform status — what is and is not implemented)
|
||||||
|
- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above)
|
||||||
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
||||||
- `CONTRIBUTING.md`
|
- `CONTRIBUTING.md`
|
||||||
|
|
||||||
@@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
|
|||||||
|
|
||||||
## Working conventions
|
## Working conventions
|
||||||
|
|
||||||
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
|
- The canonical repositories are on the church Gitea under the `Zayin` organization:
|
||||||
change branch-protection settings.
|
`ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and
|
||||||
|
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
|
||||||
|
and branch from `origin/main`, never from whatever is checked out.
|
||||||
|
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
|
||||||
|
merge your own PR, and never change branch-protection settings.
|
||||||
|
- Never create or push a release tag. Release tags belong to the human maintainer.
|
||||||
|
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
|
||||||
|
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
|
||||||
|
platform logic into a module to avoid making the correct Core change.
|
||||||
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
||||||
burying them in an implementation.
|
burying them in an implementation.
|
||||||
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
||||||
@@ -66,6 +108,13 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
|
|||||||
- Never run a database migration automatically (on import, on startup, in a test that isn't
|
- Never run a database migration automatically (on import, on startup, in a test that isn't
|
||||||
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
|
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
|
||||||
an explicit, operator-invoked action.
|
an explicit, operator-invoked action.
|
||||||
|
- Report what you find. A defect, flaky test, documentation/ADR conflict, or CI problem outside the
|
||||||
|
task at hand gets an issue in the repository it belongs to, as soon as you find it. Search for an
|
||||||
|
existing issue first; something fixed in the same PR goes in that PR's description instead. Be
|
||||||
|
descriptive: what you saw, the evidence (run and job links, commit, file and line), how to
|
||||||
|
reproduce it, the impact, and options, and never a secret. Label it `found-by-claude` (an agent
|
||||||
|
that cannot apply labels ends the body with a line saying so), leave it unassigned, and list what
|
||||||
|
you filed when you report back.
|
||||||
|
|
||||||
## Canonical verification commands
|
## Canonical verification commands
|
||||||
|
|
||||||
|
|||||||
@@ -822,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
|
|||||||
public Website remain separate future designs.
|
public Website remain separate future designs.
|
||||||
|
|
||||||
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
||||||
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing
|
||||||
|
(since added; see the Stage 1 paragraph below),
|
||||||
and no module-management, settings, or storage UI — module enablement and configuration are an API and
|
and no module-management, settings, or storage UI — module enablement and configuration are an API and
|
||||||
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
|
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
|
||||||
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
|
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
|
||||||
@@ -905,6 +906,19 @@ the module's responsibility and do not exist yet.**
|
|||||||
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
||||||
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
||||||
|
|
||||||
|
Stage 1 begins with **real Dashboard URLs**
|
||||||
|
([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The
|
||||||
|
Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own
|
||||||
|
address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark,
|
||||||
|
and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages.
|
||||||
|
The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout
|
||||||
|
each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves
|
||||||
|
the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and
|
||||||
|
discovery refuses a module that uses one. An installed module that is disabled shows a host-owned
|
||||||
|
*This page isn't available* rather than loading anything. A module's own
|
||||||
|
sub-pages, Settings and Account pages, the sidebar redesign, and returning to the workflow a Settings
|
||||||
|
link came from are later Stage 1 work.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.2.3",
|
"version": "0.2.5",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+19
-5
@@ -31,7 +31,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
|
|||||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
import { createDeviceRoutes } from "./device-routes.js";
|
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
|
||||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||||
import { createGrantsRoutes } from "./grants-routes.js";
|
import { createGrantsRoutes } from "./grants-routes.js";
|
||||||
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||||
@@ -363,10 +363,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
// own entirely. See `./health-routes.js`.
|
// own entirely. See `./health-routes.js`.
|
||||||
app.route("/", createHealthRoutes(options.health));
|
app.route("/", createHealthRoutes(options.health));
|
||||||
|
|
||||||
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
|
// The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the
|
||||||
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
// page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
|
||||||
// `./dashboard-host-routes.js`.
|
// `/<module-id>`. They grant no authority and resolve no principal; the page asks
|
||||||
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
// `/api/v1/principal` itself. See `./dashboard-host-routes.js`.
|
||||||
|
app.route(
|
||||||
|
"/",
|
||||||
|
createDashboardHostRoutes(options.dashboardHostAssets, {
|
||||||
|
modulePages: options.moduleLifecycle.snapshot.discoveredModules
|
||||||
|
.filter((module) => module.entrypoints.ui !== undefined)
|
||||||
|
.map((module) => module.manifest.id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
app.route(
|
app.route(
|
||||||
"/",
|
"/",
|
||||||
@@ -407,6 +415,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
registry: options.authorization.registry,
|
registry: options.authorization.registry,
|
||||||
runtime,
|
runtime,
|
||||||
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
||||||
|
...(options.devices
|
||||||
|
? {
|
||||||
|
renewDeviceCredential: (c: Context) =>
|
||||||
|
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.devices) {
|
if (options.devices) {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
import { moduleIdSchema } from "@ekklesia/contracts";
|
||||||
|
import { isReservedModuleId } from "@ekklesia/core/modules";
|
||||||
|
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { etagMatches } from "./conditional-request.js";
|
import { etagMatches } from "./conditional-request.js";
|
||||||
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
|
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
|
||||||
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
|
* serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
|
||||||
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
|
* this server and the shell's router read one list), and at each installed module's `/<module-id>`.
|
||||||
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
* Every one of those first segments is either a reserved platform name or a module id Core accepted,
|
||||||
* `/api/v1/modules/<module-id>`.
|
* and Core refuses a module id that is a reserved name, so no module page can shadow `/api`,
|
||||||
|
* `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display
|
||||||
|
* host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still
|
||||||
|
* lands on `/` with a query (ADR 0038).
|
||||||
*/
|
*/
|
||||||
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
||||||
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
||||||
@@ -85,30 +91,74 @@ function fixedAsset(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardHostRouteOptions {
|
||||||
|
/**
|
||||||
|
* The id of every module this process discovered that declares a Dashboard UI — enabled or not,
|
||||||
|
* since a disabled module's bookmark should open the Dashboard and say the module is unavailable
|
||||||
|
* rather than fail as an unknown address. Fixed at startup, like the rest of the module set.
|
||||||
|
*/
|
||||||
|
readonly modulePages: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each module page must be a real module id that names no platform namespace, exactly once.
|
||||||
|
* Discovery already guarantees this; the routes refuse to compose rather than trust it. */
|
||||||
|
function validatedModulePages(modulePages: readonly string[]): readonly string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const moduleId of modulePages) {
|
||||||
|
if (
|
||||||
|
!moduleIdSchema.safeParse(moduleId).success ||
|
||||||
|
isReservedModuleId(moduleId) ||
|
||||||
|
DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) ||
|
||||||
|
seen.has(moduleId)
|
||||||
|
) {
|
||||||
|
throw new DashboardHostConfigurationError();
|
||||||
|
}
|
||||||
|
seen.add(moduleId);
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
||||||
* default same-origin shape.
|
* default same-origin shape.
|
||||||
*
|
*
|
||||||
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
|
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
|
||||||
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
|
* from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
|
||||||
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
|
* platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
|
||||||
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
|
* module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
|
||||||
* the sign-in form until `/api/v1/principal` says otherwise.
|
* server change later) — always with the **same** bytes and the same validator, because the path
|
||||||
|
* selects a page inside the shell rather than a file here; that is what lets a refreshed or
|
||||||
|
* bookmarked deep link stay where it was.
|
||||||
|
*
|
||||||
|
* A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not
|
||||||
|
* installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does
|
||||||
|
* `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall through to the
|
||||||
|
* application's ordinary sanitized `404`. Like the display host's code, these bytes grant no
|
||||||
|
* authority: the page renders the sign-in form until `/api/v1/principal` says otherwise.
|
||||||
*/
|
*/
|
||||||
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
|
export function createDashboardHostRoutes(
|
||||||
|
assets: DashboardHostAssets,
|
||||||
|
options: DashboardHostRouteOptions,
|
||||||
|
): Hono {
|
||||||
const snapshot = snapshotAssets(assets);
|
const snapshot = snapshotAssets(assets);
|
||||||
|
const modulePages = validatedModulePages(options.modulePages);
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
|
const page = (ifNoneMatch: string | undefined): Response =>
|
||||||
fixedAsset(
|
fixedAsset(
|
||||||
c.req.header("if-none-match"),
|
ifNoneMatch,
|
||||||
snapshot.html,
|
snapshot.html,
|
||||||
snapshot.htmlEtag,
|
snapshot.htmlEtag,
|
||||||
"text/html; charset=utf-8",
|
"text/html; charset=utf-8",
|
||||||
DASHBOARD_PAGE_SECURITY_HEADERS,
|
DASHBOARD_PAGE_SECURITY_HEADERS,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
|
||||||
|
for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
|
||||||
|
routes.get(`/${name}`, (c) => page(c.req.header("if-none-match")));
|
||||||
|
routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match")));
|
||||||
|
}
|
||||||
|
|
||||||
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
||||||
fixedAsset(
|
fixedAsset(
|
||||||
c.req.header("if-none-match"),
|
c.req.header("if-none-match"),
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
|
|||||||
*/
|
*/
|
||||||
function writeDeviceCookie(
|
function writeDeviceCookie(
|
||||||
c: Context,
|
c: Context,
|
||||||
options: DeviceRoutesOptions,
|
options: Pick<DeviceRoutesOptions, "secureCookies">,
|
||||||
name: string,
|
name: string,
|
||||||
value: string,
|
value: string,
|
||||||
maxAgeSeconds: number,
|
maxAgeSeconds: number,
|
||||||
@@ -106,6 +106,27 @@ function writeDeviceCookie(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
|
||||||
|
*
|
||||||
|
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
|
||||||
|
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
|
||||||
|
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
|
||||||
|
*/
|
||||||
|
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
|
||||||
|
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
|
||||||
|
if (credential === undefined || credential.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeDeviceCookie(
|
||||||
|
c,
|
||||||
|
{ secureCookies },
|
||||||
|
DEVICE_SESSION_COOKIE,
|
||||||
|
credential,
|
||||||
|
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||||
const routes = new HonoApp();
|
const routes = new HonoApp();
|
||||||
const now = options.now ?? ((): Date => new Date());
|
const now = options.now ?? ((): Date => new Date());
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
|
|||||||
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||||
* surface-owned files before binding and supplies their frozen contents here. */
|
* surface-owned files before binding and supplies their frozen contents here. */
|
||||||
readonly assets?: DisplayHostAssets;
|
readonly assets?: DisplayHostAssets;
|
||||||
|
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
|
||||||
|
* deployment has device support; called only for a request that resolved to a device. */
|
||||||
|
readonly renewDeviceCredential?: (c: Context) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A composition error, detected before any display route is registered. Carries no module id,
|
/** A composition error, detected before any display route is registered. Carries no module id,
|
||||||
@@ -199,6 +202,11 @@ function mountDisplay(
|
|||||||
if (!viewer) {
|
if (!viewer) {
|
||||||
return displayFailure(c);
|
return displayFailure(c);
|
||||||
}
|
}
|
||||||
|
// Every screen asks for this on start, on reconnect, and periodically while running, so it
|
||||||
|
// is where an in-use credential's browser expiry is pushed forward.
|
||||||
|
if (principal.kind === "device") {
|
||||||
|
options.renewDeviceCredential?.(c);
|
||||||
|
}
|
||||||
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
||||||
} catch {
|
} catch {
|
||||||
return displayFailure(c);
|
return displayFailure(c);
|
||||||
|
|||||||
@@ -208,6 +208,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
|
||||||
|
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
|
||||||
|
const SHUTDOWN_SETTLE_MS = 3_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connections left on `databaseName` once a shutdown has had a moment to land.
|
||||||
|
*
|
||||||
|
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
|
||||||
|
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
|
||||||
|
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
|
||||||
|
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
|
||||||
|
*/
|
||||||
|
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
|
||||||
|
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
|
||||||
|
for (;;) {
|
||||||
|
const count = await activeConnectionCount(databaseName);
|
||||||
|
if (count === 0 || Date.now() >= deadline) {
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||||
const name = makeDatabaseName(label);
|
const name = makeDatabaseName(label);
|
||||||
await createDatabase(name);
|
await createDatabase(name);
|
||||||
@@ -1349,7 +1372,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
|
|||||||
await running.shutdown();
|
await running.shutdown();
|
||||||
|
|
||||||
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
|
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
|
||||||
const remainingConnections = await activeConnectionCount(database.name);
|
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||||
expect(remainingConnections).toBe(0);
|
expect(remainingConnections).toBe(0);
|
||||||
|
|
||||||
await dropDatabase(database.name);
|
await dropDatabase(database.name);
|
||||||
@@ -2230,7 +2253,7 @@ describe.sequential("live API — platform owner and delegated grant management"
|
|||||||
|
|
||||||
it("shuts down cleanly, closing the one shared pool", async () => {
|
it("shuts down cleanly, closing the one shared pool", async () => {
|
||||||
await running.shutdown();
|
await running.shutdown();
|
||||||
const remainingConnections = await activeConnectionCount(database.name);
|
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||||
expect(remainingConnections).toBe(0);
|
expect(remainingConnections).toBe(0);
|
||||||
await dropDatabase(database.name);
|
await dropDatabase(database.name);
|
||||||
});
|
});
|
||||||
@@ -2837,7 +2860,7 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
|
|
||||||
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
|
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
|
||||||
await running.shutdown();
|
await running.shutdown();
|
||||||
const remainingConnections = await activeConnectionCount(database.name);
|
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||||
expect(remainingConnections).toBe(0);
|
expect(remainingConnections).toBe(0);
|
||||||
// Exactly one stop, matching the exactly-one register/start already asserted above.
|
// Exactly one stop, matching the exactly-one register/start already asserted above.
|
||||||
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
|
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
|
parseModuleManifest,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -13,9 +14,13 @@ import {
|
|||||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
|
type DiscoveredModule,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
isReservedModuleId,
|
||||||
type LegacyModuleRuntimeServices,
|
type LegacyModuleRuntimeServices,
|
||||||
type ModuleDatabase,
|
type ModuleDatabase,
|
||||||
|
type ModuleLifecycleConfiguration,
|
||||||
|
RESERVED_MODULE_IDS,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@@ -33,11 +38,23 @@ import {
|
|||||||
DASHBOARD_HOST_PAGE_PATH,
|
DASHBOARD_HOST_PAGE_PATH,
|
||||||
DASHBOARD_HOST_SCRIPT_PATH,
|
DASHBOARD_HOST_SCRIPT_PATH,
|
||||||
DashboardHostConfigurationError,
|
DashboardHostConfigurationError,
|
||||||
|
createDashboardHostRoutes,
|
||||||
} from "../src/dashboard-host-routes.js";
|
} from "../src/dashboard-host-routes.js";
|
||||||
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
|
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
|
||||||
|
import {
|
||||||
|
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||||
|
MODULE_DISPLAY_PAGE_PREFIX,
|
||||||
|
} from "../src/module-display-path.js";
|
||||||
|
import {
|
||||||
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
DISPLAY_SERVICE_WORKER_PATH,
|
||||||
|
} from "../src/module-display-routes.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
import {
|
||||||
|
createInMemoryModuleLifecycleStore,
|
||||||
|
moduleLifecycleSnapshotFor,
|
||||||
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
|
||||||
@@ -78,9 +95,40 @@ function fakeModuleServices(): LegacyModuleRuntimeServices {
|
|||||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id
|
||||||
|
* and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */
|
||||||
|
function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule {
|
||||||
|
const manifest = parseModuleManifest({
|
||||||
|
capabilities: [],
|
||||||
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
...(options.ui ? { ui: "./dist/ui/index.js" } : {}),
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
manifestVersion: 8,
|
||||||
|
name: `${id} module`,
|
||||||
|
permissions: [],
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
contributions: [],
|
||||||
|
directory: `/modules/${id}`,
|
||||||
|
entrypoints: {
|
||||||
|
server: `/modules/${id}/dist/server/index.js`,
|
||||||
|
...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}),
|
||||||
|
},
|
||||||
|
manifest,
|
||||||
|
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
||||||
|
packageName: `@test/${id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildApp(
|
function buildApp(
|
||||||
options: {
|
options: {
|
||||||
readonly assets?: DashboardHostAssets;
|
readonly assets?: DashboardHostAssets;
|
||||||
|
readonly configuration?: ModuleLifecycleConfiguration;
|
||||||
|
readonly discovered?: readonly DiscoveredModule[];
|
||||||
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
@@ -100,7 +148,10 @@ function buildApp(
|
|||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
moduleUi: createModuleUiPublicationRegistry([]),
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
@@ -193,6 +244,169 @@ describe("GET / (the Dashboard page)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Dashboard deep links (ADR 0057)", () => {
|
||||||
|
const installed = () => [
|
||||||
|
discoveredModule("signage", { ui: true }),
|
||||||
|
discoveredModule("calendar", { ui: true }),
|
||||||
|
discoveredModule("retired", { ui: true }),
|
||||||
|
// A display-only module has no Dashboard page (ADR 0033).
|
||||||
|
discoveredModule("lobby-screen", { ui: false }),
|
||||||
|
];
|
||||||
|
// Installed but disabled: its address still opens the Dashboard, which says it is unavailable.
|
||||||
|
const configuration = { modules: [{ enabled: false, moduleId: "retired" }] };
|
||||||
|
|
||||||
|
const deepLinks = [
|
||||||
|
"/settings",
|
||||||
|
"/settings/",
|
||||||
|
"/settings/displays/add",
|
||||||
|
"/account",
|
||||||
|
"/account/appearance",
|
||||||
|
"/signage",
|
||||||
|
"/signage/",
|
||||||
|
// Reserved for the module's own pages, which the shell will interpret (ADR 0057).
|
||||||
|
"/signage/timelines",
|
||||||
|
"/signage/timelines/a1b2",
|
||||||
|
"/calendar",
|
||||||
|
"/retired",
|
||||||
|
];
|
||||||
|
|
||||||
|
it("serves the shell's own platform pages, from the shell's own list", () => {
|
||||||
|
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the identical page, validator, and security headers at every Dashboard page, so a refresh stays put", async () => {
|
||||||
|
const { app } = buildApp({ configuration, discovered: installed() });
|
||||||
|
|
||||||
|
for (const path of deepLinks) {
|
||||||
|
const response = await app.request(path);
|
||||||
|
expect(response.status, path).toBe(200);
|
||||||
|
expect(await response.text(), path).toBe(PAGE_HTML);
|
||||||
|
expect(response.headers.get("content-type"), path).toBe("text/html; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control"), path).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag"), path).toBe(PAGE_ETAG);
|
||||||
|
expectPageSecurityHeaders(response);
|
||||||
|
}
|
||||||
|
expect(readFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revalidates a deep link with the page's one validator", async () => {
|
||||||
|
const { app } = buildApp({ discovered: installed() });
|
||||||
|
|
||||||
|
for (const path of ["/settings/displays", "/signage"]) {
|
||||||
|
const response = await app.request(path, { headers: { "If-None-Match": PAGE_ETAG } });
|
||||||
|
expect(response.status, path).toBe(304);
|
||||||
|
expect(await response.text(), path).toBe("");
|
||||||
|
expectPageSecurityHeaders(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never turns an arbitrary top-level path into a Dashboard page", async () => {
|
||||||
|
const { app } = buildApp({ configuration, discovered: installed() });
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/definitely-not-a-real-module",
|
||||||
|
"/definitely-not-a-real-module/timelines",
|
||||||
|
// Installed, but with no Dashboard UI.
|
||||||
|
"/lobby-screen",
|
||||||
|
// Look-alikes of real pages.
|
||||||
|
"/signages",
|
||||||
|
"/signage-old",
|
||||||
|
"/Signage",
|
||||||
|
"/settings-old",
|
||||||
|
"/accounts",
|
||||||
|
// The former implementation prefix is not a namespace at all.
|
||||||
|
"/modules",
|
||||||
|
"/modules/signage",
|
||||||
|
]) {
|
||||||
|
await expectSanitizedNotFound(await app.request(path), path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves a module's page only while that module is installed", async () => {
|
||||||
|
const { app } = buildApp({ discovered: [] });
|
||||||
|
|
||||||
|
await expectSanitizedNotFound(await app.request("/signage"), "/signage");
|
||||||
|
expect((await app.request("/settings")).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the page and never the script, whatever a deep link's last segment looks like", async () => {
|
||||||
|
const { app } = buildApp({ discovered: installed() });
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/signage/app.js",
|
||||||
|
"/settings/assets/ekklesia-dashboard/app.js",
|
||||||
|
"/account/index.html",
|
||||||
|
]) {
|
||||||
|
expect(await (await app.request(path)).text(), path).toBe(PAGE_HTML);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers no other method at a Dashboard page", async () => {
|
||||||
|
const { app } = buildApp({ discovered: installed() });
|
||||||
|
|
||||||
|
for (const path of ["/settings/displays", "/signage"]) {
|
||||||
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||||
|
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses at composition a module page that would shadow a platform namespace", () => {
|
||||||
|
for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) {
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
createDashboardHostRoutes(createDashboardHostAssets(PAGE_HTML, SCRIPT), { modulePages }),
|
||||||
|
JSON.stringify(modulePages),
|
||||||
|
).toThrow(DashboardHostConfigurationError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the platform's reserved top-level names (ADR 0057)", () => {
|
||||||
|
it("cover every top-level namespace the API registers, so a module page can never shadow one", () => {
|
||||||
|
const { app } = buildApp({ discovered: [discoveredModule("signage", { ui: true })] });
|
||||||
|
|
||||||
|
const topLevel = new Set(
|
||||||
|
app.routes
|
||||||
|
.map((route) => route.path.split("/")[1] ?? "")
|
||||||
|
.filter((segment) => segment !== "" && segment !== "*"),
|
||||||
|
);
|
||||||
|
topLevel.delete("signage");
|
||||||
|
|
||||||
|
// This composition publishes no display, so the display host's routes are absent here; they
|
||||||
|
// are checked through their own path constants below.
|
||||||
|
expect([...topLevel].sort()).toEqual(["account", "api", "assets", "settings"]);
|
||||||
|
for (const segment of topLevel) {
|
||||||
|
expect(isReservedModuleId(segment), segment).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cover the display host's pages, service worker, and script, and the Dashboard's script", () => {
|
||||||
|
for (const path of [
|
||||||
|
MODULE_DISPLAY_PAGE_PREFIX,
|
||||||
|
DISPLAY_SERVICE_WORKER_PATH,
|
||||||
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
DASHBOARD_HOST_SCRIPT_PATH,
|
||||||
|
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||||
|
]) {
|
||||||
|
expect(isReservedModuleId(path.split("/")[1] ?? ""), path).toBe(true);
|
||||||
|
}
|
||||||
|
expect([...RESERVED_MODULE_IDS].sort()).toEqual([
|
||||||
|
"account",
|
||||||
|
"api",
|
||||||
|
"assets",
|
||||||
|
"display",
|
||||||
|
"settings",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("include every Dashboard platform page the shell interprets", () => {
|
||||||
|
for (const page of DASHBOARD_PLATFORM_PAGES) {
|
||||||
|
expect(RESERVED_MODULE_IDS, page).toContain(page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||||
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
||||||
const { app } = buildApp();
|
const { app } = buildApp();
|
||||||
|
|||||||
@@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
|
||||||
|
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
|
||||||
|
*/
|
||||||
|
describe("credential lifetime", () => {
|
||||||
|
const ttlSeconds = 180 * 24 * 60 * 60;
|
||||||
|
const descriptor = "/api/v1/module-displays/announcements";
|
||||||
|
|
||||||
|
function renewal(response: Response): string | undefined {
|
||||||
|
return setCookieFor(response, "ekklesia_device_session");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
|
||||||
|
const running = await platform("renewal");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||||
|
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// The same credential, re-issued with a full lifetime and the same protections.
|
||||||
|
const header = renewal(response);
|
||||||
|
expect(header).toBeDefined();
|
||||||
|
expect(header).toContain(`ekklesia_device_session=${credential};`);
|
||||||
|
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
|
||||||
|
expect(header).toMatch(/HttpOnly/u);
|
||||||
|
expect(header).toMatch(/SameSite=Strict/u);
|
||||||
|
expect(header).toMatch(/Path=\//u);
|
||||||
|
expect(header).not.toMatch(/Domain=/iu);
|
||||||
|
|
||||||
|
// Using it moved the server's window forward too.
|
||||||
|
const [row] = await query<{ recent: boolean }>(
|
||||||
|
running.url,
|
||||||
|
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
|
||||||
|
);
|
||||||
|
expect(row?.recent).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
|
||||||
|
const running = await platform("legacy");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
// A production screen paired 179 days ago that checked in this morning: under the old
|
||||||
|
// rule it had one day left. It must carry straight on.
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
|
||||||
|
);
|
||||||
|
expect((await device.request(descriptor)).status).toBe(200);
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
|
||||||
|
);
|
||||||
|
expect((await device.request(descriptor)).status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
|
||||||
|
const running = await platform("abandoned");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
|
||||||
|
);
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never renews a revoked screen", async () => {
|
||||||
|
const running = await platform("revoked-renewal");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const listed = (await (
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||||
|
).json()) as { devices: Array<{ id: string }> };
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hand a device cookie to a person viewing the display", async () => {
|
||||||
|
const running = await platform("human-renewal");
|
||||||
|
try {
|
||||||
|
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
||||||
const running = await platform("mixed");
|
const running = await platform("mixed");
|
||||||
const device = new DeviceBrowser(running.origin);
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
|||||||
@@ -195,6 +195,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
|
||||||
|
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
|
||||||
|
const SHUTDOWN_SETTLE_MS = 3_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connections left on `databaseName` once a shutdown has had a moment to land.
|
||||||
|
*
|
||||||
|
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
|
||||||
|
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
|
||||||
|
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
|
||||||
|
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
|
||||||
|
*/
|
||||||
|
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
|
||||||
|
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
|
||||||
|
for (;;) {
|
||||||
|
const count = await activeConnectionCount(databaseName);
|
||||||
|
if (count === 0 || Date.now() >= deadline) {
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||||
const name = makeDatabaseName(label);
|
const name = makeDatabaseName(label);
|
||||||
await createDatabase(name);
|
await createDatabase(name);
|
||||||
@@ -760,7 +783,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
|||||||
|
|
||||||
await running.shutdown();
|
await running.shutdown();
|
||||||
|
|
||||||
const remainingConnections = await activeConnectionCount(database.name);
|
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||||
expect(remainingConnections).toBe(0);
|
expect(remainingConnections).toBe(0);
|
||||||
|
|
||||||
const eventsAfterShutdown = readRecorder();
|
const eventsAfterShutdown = readRecorder();
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.2.3",
|
"version": "0.2.5",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -44,17 +44,26 @@ export class DeviceStoreError extends Error {
|
|||||||
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long a device credential is accepted before the device must pair again.
|
* How long a device credential may go **unused** before the device must pair again (ADR 0056).
|
||||||
*
|
*
|
||||||
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
|
* The window slides: every request from the device moves `lastSeenAt` forward, and the host
|
||||||
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
|
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
|
||||||
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
|
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
|
||||||
* decides on every single request whether the device is still revoked, so an administrator's revoke
|
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops
|
||||||
* takes effect immediately regardless of how long the credential would otherwise have lived. The
|
* being an identity at all: 180 days after it was last seen, its credential is dead.
|
||||||
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
|
|
||||||
*/
|
*/
|
||||||
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
||||||
|
|
||||||
|
/** When a device credential expires: the idle window, measured from the last time the device was
|
||||||
|
* seen, or from pairing if it never has been. */
|
||||||
|
export function deviceCredentialExpiresAt(input: {
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly lastSeenAt?: Date | null | undefined;
|
||||||
|
}): Date {
|
||||||
|
const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0);
|
||||||
|
return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
||||||
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
||||||
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
||||||
@@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
|||||||
}
|
}
|
||||||
// The credential's own horizon, measured from when the device was paired. Revocation is
|
// The credential's own horizon, measured from when the device was paired. Revocation is
|
||||||
// checked above and is what makes a long horizon acceptable.
|
// checked above and is what makes a long horizon acceptable.
|
||||||
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
|
// Expiry is measured from last use, not from pairing, so a screen in use never ages out.
|
||||||
|
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
safeParseModuleManifest,
|
safeParseModuleManifest,
|
||||||
semanticVersionSchema,
|
semanticVersionSchema,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import { isReservedModuleId } from "./reserved-ids.js";
|
||||||
|
|
||||||
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode =
|
|||||||
| "MODULE_ENTRYPOINT_INVALID"
|
| "MODULE_ENTRYPOINT_INVALID"
|
||||||
| "MODULE_ENTRYPOINT_MISSING"
|
| "MODULE_ENTRYPOINT_MISSING"
|
||||||
| "MODULE_ID_DUPLICATE"
|
| "MODULE_ID_DUPLICATE"
|
||||||
|
| "MODULE_ID_RESERVED"
|
||||||
| "MODULE_MANIFEST_INVALID"
|
| "MODULE_MANIFEST_INVALID"
|
||||||
| "MODULE_MANIFEST_MISSING"
|
| "MODULE_MANIFEST_MISSING"
|
||||||
| "MODULE_PACKAGE_INVALID"
|
| "MODULE_PACKAGE_INVALID"
|
||||||
@@ -614,6 +616,18 @@ async function scanModuleDirectory(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isReservedModuleId(manifest.id)) {
|
||||||
|
// A module's Dashboard page is `/<module-id>` (ADR 0057), in the same URL space as the
|
||||||
|
// platform's own top-level namespaces.
|
||||||
|
issues.push({
|
||||||
|
code: "MODULE_ID_RESERVED",
|
||||||
|
message: `Module ID ${manifest.id} is reserved: the platform uses /${manifest.id} for its own pages and routes.`,
|
||||||
|
moduleDirectory: directoryName,
|
||||||
|
moduleId: manifest.id,
|
||||||
|
path: "id",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||||
if (!artifacts) {
|
if (!artifacts) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export * from "./module-package-host.js";
|
|||||||
export * from "./module-package-verification.js";
|
export * from "./module-package-verification.js";
|
||||||
export * from "./permissions.js";
|
export * from "./permissions.js";
|
||||||
export * from "./realtime.js";
|
export * from "./realtime.js";
|
||||||
|
export * from "./reserved-ids.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
export * from "./schema-discovery.js";
|
export * from "./schema-discovery.js";
|
||||||
export * from "./ui-publication.js";
|
export * from "./ui-publication.js";
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Top-level names the platform owns, which therefore can never be a module id (ADR 0057).
|
||||||
|
*
|
||||||
|
* Every module with a Dashboard UI is addressed at `/<module-id>`, beside the platform's own
|
||||||
|
* top-level namespaces in one URL space. A module called `settings` would collide with the
|
||||||
|
* Dashboard's Settings pages, and one called `api` with the API itself. This is the complete set the
|
||||||
|
* platform serves today, not a guess at what it might want later:
|
||||||
|
*
|
||||||
|
* - `api` — the API: identity, principal, health, Core resources, module routes, module UI artifacts,
|
||||||
|
* display descriptors, and realtime (`/api/…`);
|
||||||
|
* - `assets` — the Dashboard's and display host's scripts (`/assets/ekklesia-dashboard/…`,
|
||||||
|
* `/assets/ekklesia-display/…`);
|
||||||
|
* - `display` — the display host's pages and service worker (`/display/…`);
|
||||||
|
* - `settings` and `account` — the Dashboard's own Settings and account areas.
|
||||||
|
*
|
||||||
|
* `apps/api` holds the list to the routes it actually registers, and to the Dashboard namespaces the
|
||||||
|
* shell interprets, so a new top-level platform route cannot be added without reserving it here.
|
||||||
|
* Reserving a name is a breaking change for any installation with a module of that id, so the set
|
||||||
|
* grows only deliberately.
|
||||||
|
*/
|
||||||
|
export const RESERVED_MODULE_IDS = Object.freeze([
|
||||||
|
"account",
|
||||||
|
"api",
|
||||||
|
"assets",
|
||||||
|
"display",
|
||||||
|
"settings",
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);
|
||||||
|
|
||||||
|
export function isReservedModuleId(moduleId: string): boolean {
|
||||||
|
return reserved.has(moduleId);
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import path from "node:path";
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
isReservedModuleId,
|
||||||
ModuleDiscoveryError,
|
ModuleDiscoveryError,
|
||||||
type ModuleDiscoveryIssueCode,
|
type ModuleDiscoveryIssueCode,
|
||||||
|
RESERVED_MODULE_IDS,
|
||||||
} from "../src/modules/index.js";
|
} from "../src/modules/index.js";
|
||||||
|
|
||||||
interface ModuleFixtureOptions {
|
interface ModuleFixtureOptions {
|
||||||
@@ -271,6 +273,52 @@ describe("module discovery", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a module whose id is one of the platform's reserved top-level names (ADR 0057)", async () => {
|
||||||
|
// Every module with a Dashboard UI is addressed at `/<module-id>`, so an id equal to a
|
||||||
|
// platform-owned top-level namespace would collide with the API, the display host, a host
|
||||||
|
// script, or a Dashboard page.
|
||||||
|
expect(RESERVED_MODULE_IDS).toEqual(["account", "api", "assets", "display", "settings"]);
|
||||||
|
expect(Object.isFrozen(RESERVED_MODULE_IDS)).toBe(true);
|
||||||
|
|
||||||
|
for (const id of RESERVED_MODULE_IDS) {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, id);
|
||||||
|
|
||||||
|
const error = await discoveryError(modulesDirectory);
|
||||||
|
|
||||||
|
expect(error.issues, id).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "MODULE_ID_RESERVED",
|
||||||
|
moduleDirectory: id,
|
||||||
|
moduleId: id,
|
||||||
|
path: "id",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts ids that merely resemble a reserved name", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
for (const id of ["settings-sync", "my-account", "displays", "api-docs"]) {
|
||||||
|
await createModule(modulesDirectory, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(modules.map((module) => module.manifest.id).sort()).toEqual([
|
||||||
|
"api-docs",
|
||||||
|
"displays",
|
||||||
|
"my-account",
|
||||||
|
"settings-sync",
|
||||||
|
]);
|
||||||
|
expect(isReservedModuleId("settings")).toBe(true);
|
||||||
|
expect(isReservedModuleId("settings-sync")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("enforces platform and Module SDK compatibility", async () => {
|
it("enforces platform and Module SDK compatibility", async () => {
|
||||||
const modulesDirectory = await createModulesDirectory();
|
const modulesDirectory = await createModulesDirectory();
|
||||||
await createModule(modulesDirectory, "future", {
|
await createModule(modulesDirectory, "future", {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -42,9 +42,16 @@
|
|||||||
|
|
||||||
{$EKKLESIA_HOSTNAME} {
|
{$EKKLESIA_HOSTNAME} {
|
||||||
# Everything the API serves: the Dashboard at /, the API, display pages and their service
|
# Everything the API serves: the Dashboard at /, the API, display pages and their service
|
||||||
# worker, and the Dashboard and display host scripts. Any other path is refused here.
|
# worker, and the Dashboard and display host scripts.
|
||||||
@ekklesia path / /api/* /display/* /assets/*
|
@ekklesia path / /api/* /display/* /assets/*
|
||||||
|
|
||||||
|
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
|
||||||
|
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
|
||||||
|
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
|
||||||
|
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
|
||||||
|
# Any other path is refused here.
|
||||||
|
@dashboardPage path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
|
||||||
|
|
||||||
# Module realtime is Server-Sent Events: flush every event immediately.
|
# Module realtime is Server-Sent Events: flush every event immediately.
|
||||||
@realtime path /api/v1/module-realtime/*
|
@realtime path /api/v1/module-realtime/*
|
||||||
|
|
||||||
@@ -63,6 +70,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handle @dashboardPage {
|
||||||
|
reverse_proxy api:3000 {
|
||||||
|
import ekklesia_forwarding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handle {
|
handle {
|
||||||
respond 404
|
respond 404
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,12 +51,28 @@
|
|||||||
{$EKKLESIA_DASHBOARD_HOSTNAME} {
|
{$EKKLESIA_DASHBOARD_HOSTNAME} {
|
||||||
@dashboard path / /assets/ekklesia-dashboard/*
|
@dashboard path / /assets/ekklesia-dashboard/*
|
||||||
|
|
||||||
|
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
|
||||||
|
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
|
||||||
|
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
|
||||||
|
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
|
||||||
|
# The API's own namespaces stay refused on this hostname.
|
||||||
|
@dashboardPage {
|
||||||
|
path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
|
||||||
|
not path /api /api/* /display /display/* /assets /assets/*
|
||||||
|
}
|
||||||
|
|
||||||
handle @dashboard {
|
handle @dashboard {
|
||||||
reverse_proxy api:3000 {
|
reverse_proxy api:3000 {
|
||||||
import ekklesia_forwarding
|
import ekklesia_forwarding
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handle @dashboardPage {
|
||||||
|
reverse_proxy api:3000 {
|
||||||
|
import ekklesia_forwarding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handle {
|
handle {
|
||||||
respond 404
|
respond 404
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-5
@@ -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
|
||||||
|
|
||||||
@@ -240,6 +241,14 @@ Module storage is installation state: back it up with the database and the bucke
|
|||||||
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
|
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
|
||||||
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||||
|
|
||||||
|
**Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with
|
||||||
|
the new `Caddyfile.example` (or `Caddyfile.split.example`). Each gains a `@dashboardPage` matcher
|
||||||
|
and `handle` block that forward every top-level path shaped like a module id — `/settings`,
|
||||||
|
`/account`, and each installed module's page such as `/signage` — to the API, which answers with
|
||||||
|
the Dashboard page where one exists and its sanitized `404` elsewhere. Without it the Dashboard still
|
||||||
|
opens at `/`, but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the
|
||||||
|
proxy after editing it. Installing or removing a module needs no proxy change.
|
||||||
|
|
||||||
### Rolling back
|
### Rolling back
|
||||||
|
|
||||||
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
|
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
|
||||||
@@ -262,7 +271,7 @@ The same image serves both; only configuration differs.
|
|||||||
|
|
||||||
| Hostname | Serves | Refuses (`404`) |
|
| Hostname | Serves | Refuses (`404`) |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, `/assets/ekklesia-dashboard/*`, and every module-shaped top-level path (the Dashboard's pages, such as `/settings` and `/signage`; the API answers the rest with its `404`) | everything else, including `/api/*`, `/display/*`, and `/assets/*` |
|
||||||
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
|
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
|
||||||
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
|
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
|
||||||
|
|
||||||
@@ -338,7 +347,11 @@ networks:
|
|||||||
|
|
||||||
Then make your proxy do what `Caddyfile.example` does:
|
Then make your proxy do what `Caddyfile.example` does:
|
||||||
|
|
||||||
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
|
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`,
|
||||||
|
and every path whose first segment is shaped like a module id, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$` (ADR 0057): that is
|
||||||
|
`/settings`, `/account`, and each installed module's page such as `/signage`, and the API answers
|
||||||
|
anything that is not a real page with its sanitized `404`. Without it, refreshing any Dashboard page
|
||||||
|
other than `/` is your proxy's `404`. Do not list module ids: they change as modules are installed;
|
||||||
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
|
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
|
||||||
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
|
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
|
||||||
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
|
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
|
||||||
@@ -404,7 +417,7 @@ deployment:
|
|||||||
|
|
||||||
| # | Item | Where it is met |
|
| # | Item | Where it is met |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
|
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*`, and every module-shaped top-level path (the Dashboard's pages) on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
|
||||||
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
|
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
|
||||||
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
|
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
|
||||||
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
|
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
|
||||||
@@ -511,10 +524,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.5 \
|
||||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||||
--tag ekklesia:0.2.3 \
|
--tag ekklesia:0.2.5 \
|
||||||
.
|
.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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,130 @@
|
|||||||
|
# Setting up a display kiosk
|
||||||
|
|
||||||
|
A display is a screen — a television with a small computer, or a mini PC on a wall — that opens one
|
||||||
|
module's display page full screen and keeps it open unattended. This guide sets one up so it starts
|
||||||
|
on boot, stays paired, recovers from network and server outages on its own, and never opens a second
|
||||||
|
copy of itself.
|
||||||
|
|
||||||
|
Pairing and display identity belong to the platform (ADR 0036). Today an administrator approves a
|
||||||
|
screen in the Dashboard sidebar's **Display devices** panel; a later release moves this to
|
||||||
|
**Settings → Displays**. Nothing below changes when that happens.
|
||||||
|
|
||||||
|
## What the screen needs
|
||||||
|
|
||||||
|
- Chromium or Google Chrome, current stable. The display page is tested against Chromium.
|
||||||
|
- A **dedicated, persistent browser profile directory**. The device credential is an HttpOnly cookie
|
||||||
|
in that profile; it is what keeps the screen paired across reboots. Never use a guest, incognito, or
|
||||||
|
"clear data on exit" profile, and never share the profile with a person's browsing.
|
||||||
|
- Network access to the installation's **API hostname** (the display page, its realtime stream, and
|
||||||
|
its module requests) and to the storage hostname that serves images.
|
||||||
|
- A clock that is roughly right. Playback follows the server's clock, but TLS does not tolerate a
|
||||||
|
wildly wrong one.
|
||||||
|
|
||||||
|
## The address
|
||||||
|
|
||||||
|
Every display-capable module has one display page on the API hostname:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://<api-hostname>/display/<module-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
For Signage that is `https://<api-hostname>/display/signage`. The Signage **Displays** tab shows the
|
||||||
|
exact address under **Copy setup address**. Open it on the screen itself; opening it on an
|
||||||
|
administrator's own computer only previews the display (and that browser appears in the roster as a
|
||||||
|
*Signed-in browser*, which can be removed once it is offline).
|
||||||
|
|
||||||
|
## Launching the browser
|
||||||
|
|
||||||
|
Run exactly **one** launcher, and launch the page as an app window with a fixed profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chromium \
|
||||||
|
--user-data-dir=/var/lib/display-kiosk/profile \
|
||||||
|
--app=https://<api-hostname>/display/signage \
|
||||||
|
--kiosk \
|
||||||
|
--no-first-run \
|
||||||
|
--noerrdialogs \
|
||||||
|
--disable-session-crashed-bubble \
|
||||||
|
--hide-crash-restore-bubble \
|
||||||
|
--disable-features=Translate \
|
||||||
|
--autoplay-policy=no-user-gesture-required \
|
||||||
|
--check-for-update-interval=31536000
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--app` opens the page with no tab strip and no address bar. `--kiosk` makes it full screen.
|
||||||
|
- `--user-data-dir` is the persistent profile that holds the pairing. Keep it on persistent storage
|
||||||
|
and back it up with nothing: if it is lost, the screen simply shows a new pairing code.
|
||||||
|
- Do **not** add `--restore-last-session`, and do not also start the browser from a desktop autostart
|
||||||
|
entry. Chromium allows one process per profile; a second launch hands its URL to the running
|
||||||
|
process, which opens a **second window or tab** of the display. That is the usual cause of
|
||||||
|
"duplicate" displays. One launcher, one profile, one window.
|
||||||
|
|
||||||
|
### Start on boot and restart on exit (systemd)
|
||||||
|
|
||||||
|
Run the browser as an unprivileged user in that user's graphical session, from a single user unit:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# ~/.config/systemd/user/display-kiosk.service
|
||||||
|
[Unit]
|
||||||
|
Description=Display kiosk
|
||||||
|
After=graphical-session.target network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/chromium --user-data-dir=%h/.local/share/display-kiosk --app=https://<api-hostname>/display/signage --kiosk --no-first-run --noerrdialogs --disable-session-crashed-bubble --hide-crash-restore-bubble --disable-features=Translate --autoplay-policy=no-user-gesture-required --check-for-update-interval=31536000
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now display-kiosk.service
|
||||||
|
loginctl enable-linger <kiosk-user> # with the desktop set to log this user in automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
`systemctl --user stop display-kiosk` closes the browser cleanly, which is what keeps its profile —
|
||||||
|
and the pairing cookie — intact. Avoid cutting power to a running kiosk when you can: a clean shutdown
|
||||||
|
is always safer for a browser profile.
|
||||||
|
|
||||||
|
## Pairing
|
||||||
|
|
||||||
|
1. Start the kiosk. With no credential it shows a **six-digit pairing code**.
|
||||||
|
2. In the Dashboard, enter the code under **Display devices**, give the screen a name, and approve.
|
||||||
|
3. Within a few seconds the screen collects its credential and starts showing content.
|
||||||
|
4. In Signage → **Displays**, name the display and choose its group and audience.
|
||||||
|
|
||||||
|
A paired screen stays paired across browser restarts and reboots, and for as long as it is in use:
|
||||||
|
its credential is renewed each time it checks in, and only expires after **180 days without being
|
||||||
|
used** (ADR 0056). A screen that is switched off and stored for longer than that, or that is revoked,
|
||||||
|
returns to its pairing code and is paired again the same way.
|
||||||
|
|
||||||
|
## What happens when things go wrong
|
||||||
|
|
||||||
|
| Event | What the screen does |
|
||||||
|
| --- | --- |
|
||||||
|
| Network drops for seconds | Nothing visible. The display keeps running; the realtime stream reconnects. |
|
||||||
|
| Network or API down for longer | Keeps playing what it has cached, with a small corner note; recovers by itself when the server is back. No remount, no reload. |
|
||||||
|
| API restarted or upgraded | Reconnects by itself. A new module version is picked up within five minutes and swapped in. |
|
||||||
|
| Browser crashes or is restarted | systemd restarts it; the same profile is still paired. |
|
||||||
|
| Machine reboots | Starts on boot and resumes. If the server is not up yet, the screen retries on its own (every few seconds, backing off to once a minute) until it is. |
|
||||||
|
| Display revoked in the Dashboard | Stops showing content within seconds and returns to its pairing code. |
|
||||||
|
| Display moved to another group or audience | Switches content within about one heartbeat (≈12 s), no reload. |
|
||||||
|
|
||||||
|
**Offline cold start is not supported yet.** A screen that is already showing content survives an
|
||||||
|
outage. A screen that *boots* while the server is completely unreachable can load its cached page but
|
||||||
|
cannot mount the module until the server answers, because the authorization it needs is never cached
|
||||||
|
(ADR 0043, Signage ADR 0006). It shows a short message and retries on its own until the server is
|
||||||
|
reachable — nobody needs to touch it.
|
||||||
|
|
||||||
|
## Checklist for a new kiosk
|
||||||
|
|
||||||
|
- [ ] One systemd unit, no desktop autostart entry, no second launcher.
|
||||||
|
- [ ] `--user-data-dir` on persistent storage, not shared, not cleared on exit.
|
||||||
|
- [ ] Automatic login of the kiosk user, and screen blanking/power saving disabled.
|
||||||
|
- [ ] Paired and named; assigned in Signage.
|
||||||
|
- [ ] Unplug the network for a minute: content keeps playing, then the note clears on reconnect.
|
||||||
|
- [ ] Reboot: the screen comes back paired without anyone touching it.
|
||||||
@@ -267,6 +267,8 @@ to follow — and the API never builds frontend source at run time.
|
|||||||
and its only allowed build script, and a second toolchain buys HMR that a proof does not need.
|
and its only allowed build script, and a second toolchain buys HMR that a proof does not need.
|
||||||
- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus
|
- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus
|
||||||
module routing is a design this pass should not settle by accident.
|
module routing is a design this pass should not settle by accident.
|
||||||
|
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
|
||||||
|
> now has host-owned URL routing; module routing remains a separate, deferred decision.
|
||||||
- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs
|
- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs
|
||||||
to the host, and adopting it here for a two-item selector would prejudge that design.
|
to the host, and adopting it here for a two-item selector would prejudge that design.
|
||||||
- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
|
- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
|
||||||
|
|||||||
@@ -255,6 +255,10 @@ that is unremarkable, and it is measured work rather than hidden.
|
|||||||
|
|
||||||
## Deferred
|
## Deferred
|
||||||
|
|
||||||
|
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** Client-side
|
||||||
|
> routing and deep links to a selected module, deferred below, have landed; the rest of this list is
|
||||||
|
> unchanged.
|
||||||
|
|
||||||
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
|
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
|
||||||
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
|
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
|
||||||
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
|
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ redirect target.
|
|||||||
A future client router may define an internal return-location contract, but it must be a separately
|
A future client router may define an internal return-location contract, but it must be a separately
|
||||||
validated, same-origin route decision. This pass does not create one speculatively.
|
validated, same-origin route decision. This pass does not create one speculatively.
|
||||||
|
|
||||||
|
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
|
||||||
|
> now has a host-owned router. It adds no return-location contract: sign-in still happens in place at
|
||||||
|
> the current URL, which is now a real page address.
|
||||||
|
|
||||||
### Cookie and origin policy does not broaden for the Dashboard
|
### Cookie and origin policy does not broaden for the Dashboard
|
||||||
|
|
||||||
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
|
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
- Status: Accepted
|
- Status: Accepted
|
||||||
- Date: 2026-08-24
|
- Date: 2026-08-24
|
||||||
|
- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md)
|
||||||
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
|
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
|
||||||
[0032](0032-native-module-realtime-transport.md),
|
[0032](0032-native-module-realtime-transport.md),
|
||||||
[0033](0033-display-kiosk-host-contract.md),
|
[0033](0033-display-kiosk-host-contract.md),
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 0056: A device credential lasts while the device is in use
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-12
|
||||||
|
- Supersedes: the "Session lifetime" section of [0036](0036-managed-device-identity-and-pairing.md).
|
||||||
|
Everything else in ADR 0036 — the device as its own principal, pairing, revocation on every
|
||||||
|
request, re-pairing as a new identity — is unchanged.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0036 gave a device credential a fixed 180 days from pairing. The intent was that a screen on a
|
||||||
|
ceiling should not need re-pairing on a short cycle, and that an abandoned screen should eventually
|
||||||
|
stop being an identity.
|
||||||
|
|
||||||
|
A fixed horizon does the second and fails the first. Every screen in the building expires on the
|
||||||
|
anniversary of its pairing whether or not it was in use the day before, and drops to a pairing code
|
||||||
|
in front of the congregation until someone with Dashboard access walks to it. For screens paired
|
||||||
|
together, that is every screen at once. Nothing about a working, unrevoked screen is less trustworthy
|
||||||
|
on day 181 than on day 179; only the calendar changed.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The 180 days are an idle window, not a lifetime.** A device credential is accepted while the
|
||||||
|
device was last seen less than 180 days ago (or, if it has never been seen, was paired less than 180
|
||||||
|
days ago). `lastSeenAt` already moves forward on authenticated device requests (throttled to one
|
||||||
|
write per 15 minutes), so the server-side window slides with use and needs no new column or
|
||||||
|
migration.
|
||||||
|
|
||||||
|
**The browser's copy is renewed by the host, not rotated.** The display descriptor —
|
||||||
|
`GET /api/v1/module-displays/<module>`, which every display requests when it starts, when it
|
||||||
|
reconnects, and periodically while it runs — re-issues the device cookie that authenticated the
|
||||||
|
request with a fresh 180-day `Max-Age` and the same attributes as at pairing (`HttpOnly`,
|
||||||
|
`SameSite=Strict`, host-only, `Path=/`, `Secure` on https). The value is unchanged.
|
||||||
|
|
||||||
|
Renewal happens only for a request that already resolved to a live, unrevoked device. A revoked,
|
||||||
|
expired, or unknown credential is anonymous, as before, and is never renewed; a person viewing a
|
||||||
|
display never receives a device cookie.
|
||||||
|
|
||||||
|
**Revocation remains the boundary.** It is still checked on every request, and an administrator's
|
||||||
|
revoke still takes effect immediately.
|
||||||
|
|
||||||
|
## Why not rotate
|
||||||
|
|
||||||
|
Rotation (issue a new value, invalidate the old) would bound how long any one stolen value works and
|
||||||
|
could reveal a cloned credential. For an unattended screen it trades that for a new way to strand
|
||||||
|
the display: a renewal response lost to a network drop, a power cut, or a browser that never
|
||||||
|
persisted the new cookie leaves the screen holding a dead credential and showing a pairing code,
|
||||||
|
which is the failure this decision exists to remove. A refresh-token scheme with overlap windows
|
||||||
|
could avoid that, at the cost of real complexity in the one place mistakes are invisible until a
|
||||||
|
wall goes dark. That is not justified while revocation is immediate and a device principal can only
|
||||||
|
read the displays it was paired for.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A screen that is used at least once every 180 days stays paired until an administrator revokes
|
||||||
|
it. An abandoned screen still stops being an identity 180 days after it was last seen.
|
||||||
|
- Existing credentials need no migration. A screen paired under the fixed rule is renewed the next
|
||||||
|
time it requests its descriptor, provided its browser still holds the cookie.
|
||||||
|
- A stolen device credential stays usable for as long as anyone keeps using it, until it is revoked.
|
||||||
|
Under ADR 0036 it was usable for at most 180 days from pairing. The exposure is bounded by what a
|
||||||
|
device may do — the display permissions of the module it was paired for, never a person's access
|
||||||
|
— and by revocation. Operators can see each device's last-seen time and revoke anything
|
||||||
|
unexpected.
|
||||||
|
- A credential is not bound to a browser or network. Like a human session cookie, whoever presents
|
||||||
|
it is the device.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Keep the fixed 180 days.** Rejected: every in-use screen fails on a schedule, and screens paired
|
||||||
|
together fail together.
|
||||||
|
- **Never expire.** Rejected: an abandoned or forgotten screen would remain an identity forever, and
|
||||||
|
its credential would never stop working without someone remembering to revoke it.
|
||||||
|
- **Rotate on renewal.** Rejected for now; see above.
|
||||||
|
- **Renew on every authenticated response.** Rejected: principal resolution happens deep in shared
|
||||||
|
middleware that never writes responses. One endpoint every display already requests periodically
|
||||||
|
is enough, and keeps the behaviour in one visible place.
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# 0057: Dashboard URLs are real, host-owned routes
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-14
|
||||||
|
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||||
|
[0027](0027-dashboard-navigation-and-flat-modules.md),
|
||||||
|
[0034](0034-host-owned-browser-authentication.md),
|
||||||
|
[0044](0044-self-hosted-reference-deployment.md),
|
||||||
|
[0045](0045-split-dashboard-and-api-origins.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Dashboard had exactly one URL. `apps/api` served the page at `/` and nowhere else, both
|
||||||
|
reference proxies forwarded only `/` to it, and the shell kept "which module is open" in React state.
|
||||||
|
Opening a module never changed the address bar, so a refresh always returned to the first module,
|
||||||
|
nothing could be bookmarked or shared, and the browser's Back button left the Dashboard entirely.
|
||||||
|
ADR 0026 rejected a client router *for that pass* — "a selected-module state is sufficient" — and
|
||||||
|
ADR 0027 deferred client routing and deep links.
|
||||||
|
|
||||||
|
The product contract now requires them. DESIGN.md §2.7 says the URL represents the current place in
|
||||||
|
the product: a refresh returns to the same page, deep links work, Back and Forward behave
|
||||||
|
predictably. §5 makes an **Overview** the Dashboard's landing page, and §12.6 says a disabled module's
|
||||||
|
routes stop behaving as active routes. Everything else Stage 1 builds — the sidebar, Settings, the
|
||||||
|
account area, platform Displays, a module's own pages in the sidebar, and returning to the workflow a
|
||||||
|
Settings deep link came from — needs stable addresses to hang from, so this is the first slice.
|
||||||
|
|
||||||
|
A route that works in the shell but not on refresh is worse than no route, because it looks like it
|
||||||
|
works. Refresh is a server and proxy concern before it is a browser one: the API had to serve the
|
||||||
|
page at the new paths, and the proxies had to forward them.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### One top-level URL space, shared by platform pages and module pages
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ the Overview
|
||||||
|
/settings[/…] Settings
|
||||||
|
/account[/…] the account area
|
||||||
|
/<module-id> a module's page: /signage, /calendar, /people
|
||||||
|
/<module-id>/<page>[/…] reserved for that module's own pages: /signage/timelines
|
||||||
|
```
|
||||||
|
|
||||||
|
A module is a first-class top-level destination, addressed by its id, exactly as Settings and the
|
||||||
|
Account area are. "Module" is an implementation concept — how the platform packages a product — and
|
||||||
|
it does not appear in the addresses people see, bookmark, and share. A module id is lowercase
|
||||||
|
kebab-case and Core-validated, which is what makes it usable as a path segment with no further
|
||||||
|
encoding.
|
||||||
|
|
||||||
|
### Platform-owned top-level names are reserved, and Core enforces it
|
||||||
|
|
||||||
|
Sharing one namespace means a module must never be able to take a name the platform uses.
|
||||||
|
`RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`) is the complete set the platform serves
|
||||||
|
at the top level **today**, taken from what is actually registered rather than from guesses:
|
||||||
|
|
||||||
|
| Name | Owned by |
|
||||||
|
| --- | --- |
|
||||||
|
| `api` | the API: identity, principal, health, Core resources, module routes, module UI artifacts, display descriptors, realtime |
|
||||||
|
| `assets` | the Dashboard's and display host's scripts |
|
||||||
|
| `display` | the display host's pages and service worker |
|
||||||
|
| `settings` | the Dashboard's Settings pages |
|
||||||
|
| `account` | the Dashboard's account area |
|
||||||
|
|
||||||
|
Discovery refuses a module that declares one (`MODULE_ID_RESERVED`), so the check covers API
|
||||||
|
startup, `pnpm modules:validate`, and Module Package verification — before a module is installed,
|
||||||
|
not only when it is served. Exact names only: `settings-sync` or `displays` are ordinary ids.
|
||||||
|
|
||||||
|
The set is held to reality by tests in `apps/api` rather than by review: every top-level segment the
|
||||||
|
composed API registers is a reserved name or a module page, the display host's and Dashboard
|
||||||
|
script's path constants all start with a reserved name, and every Dashboard page the shell
|
||||||
|
interprets is reserved. Adding a new top-level platform route therefore fails a test until its name
|
||||||
|
is reserved. Reserving a name later is a **breaking change** for any installation with a module of
|
||||||
|
that id, so the set grows only deliberately, and only for a real route.
|
||||||
|
|
||||||
|
The rule lives in Core's discovery rather than in the module manifest grammar
|
||||||
|
(`@ekklesia/contracts`), because it is the Ekklesia host's URL space, not a property of a module: a
|
||||||
|
standalone host (ADR 0021) has no Settings page to protect. A module built against the current
|
||||||
|
public packages is unaffected unless it uses one of these five names.
|
||||||
|
|
||||||
|
### The server serves one page, unchanged, at every real Dashboard address — and nowhere else
|
||||||
|
|
||||||
|
`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at `/settings` and
|
||||||
|
`/account` and everything below each (`DASHBOARD_PLATFORM_PAGES`, exported by the shell as
|
||||||
|
`@ekklesia/shell/dashboard-routes`, so the server and the shell's router read one list), and at
|
||||||
|
`/<module-id>` and everything below it for **each module this process discovered that declares a
|
||||||
|
Dashboard UI**. Every answer is the same frozen bytes, the same strong `ETag`, and the same security
|
||||||
|
headers as `/`: the path selects a page inside the shell, never a file on the server. Nothing from
|
||||||
|
the request is composed into a lookup, no directory is exposed, and the script and source map remain
|
||||||
|
reachable only at their own literal paths.
|
||||||
|
|
||||||
|
The module set is the boot snapshot's discovered inventory, fixed at startup like every other
|
||||||
|
module-derived route, and it deliberately **includes disabled and blocked modules**. A bookmark to a
|
||||||
|
module an administrator turned off still opens the Dashboard, which says the module is unavailable;
|
||||||
|
that is more useful than an address that suddenly looks like it never existed. A module that is not
|
||||||
|
installed, or that has no Dashboard UI (a display-only module, ADR 0033), has no page.
|
||||||
|
|
||||||
|
Everything else stays the sanitized `404`: an arbitrary root path (`/definitely-not-a-real-module`),
|
||||||
|
a look-alike (`/settings-old`, `/signages`), another method, `/index.html`, and so on. The routes
|
||||||
|
also refuse to compose if handed a module page that is not a valid module id, is a reserved name, or
|
||||||
|
appears twice — discovery already guarantees all three, and the routes do not trust it.
|
||||||
|
|
||||||
|
Serving `/<module-id>` before sign-in means an anonymous request can learn whether a module with a
|
||||||
|
Dashboard UI is installed, by its `200` or `404`. That is accepted: it is the same disclosure the
|
||||||
|
display host already makes at `/display/<module-id>` (ADR 0033), a module id names a product rather
|
||||||
|
than any data, and the alternative — a page at every module-shaped root path — is exactly the
|
||||||
|
"arbitrary path becomes a Dashboard page" this decision rejects. The authenticated module inventory
|
||||||
|
and every module route keep their authorization unchanged.
|
||||||
|
|
||||||
|
### The proxies forward module-shaped names and let the API decide
|
||||||
|
|
||||||
|
A proxy cannot know which modules an installation has, and a proxy configuration listing them would
|
||||||
|
have to be edited on every install. Both reference Caddyfiles therefore forward every top-level path
|
||||||
|
whose first segment has a module id's shape, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$`, and the API
|
||||||
|
answers it with the page or its sanitized `404`. Anything not shaped like one — `/favicon.ico`,
|
||||||
|
`/.well-known/…`, an uppercase name — is still refused by the proxy without reaching the API. In the
|
||||||
|
split topology (ADR 0045) the Dashboard hostname forwards module-shaped names but still refuses
|
||||||
|
`/api`, `/display`, and `/assets`, and the API hostname still serves no Dashboard page. The
|
||||||
|
development server falls back to the page for any path with no file.
|
||||||
|
|
||||||
|
### The shell's router is host-owned, small, and deliberately not a library
|
||||||
|
|
||||||
|
The address bar is the source of truth for which page is open. The shell reads it through a
|
||||||
|
four-method `HostHistory` (`pathname`, `push`, `replace`, `subscribe`); the browser implementation is
|
||||||
|
built in the entry point from `window` and handed down, so the entry point remains the only file in
|
||||||
|
the package that touches a browser global, and every routing behaviour is tested against an in-memory
|
||||||
|
history whose entries a test can inspect. No state object is ever stored in a history entry — the path
|
||||||
|
alone is meaningful — so nothing put into history can be read back as authority.
|
||||||
|
|
||||||
|
`react-router-dom` is not adopted. Modules are refused it because routing belongs to the host
|
||||||
|
(ADR 0022, `pnpm boundaries`); the shell's own pages are a handful of host screens plus one mounted
|
||||||
|
module; and adopting a library now would prejudge how a module's own pages are addressed, which is
|
||||||
|
a separate decision (see **Deferred**).
|
||||||
|
|
||||||
|
### What each path shows
|
||||||
|
|
||||||
|
- `/` is the **Overview**. The shell no longer opens the first module for the person: the Overview is
|
||||||
|
the landing page. Until modules can contribute cards to it, it says plainly that it has nothing to
|
||||||
|
show yet.
|
||||||
|
- `/<module-id>` mounts the module whose navigation entry has that id, through the unchanged generic
|
||||||
|
loader and the unchanged keyed frame, so a stale load still cannot reach a newer page. The server's
|
||||||
|
navigation is the only authority on what is mountable. An id it does not list — a disabled or
|
||||||
|
blocked module whose address the server still serves — shows *This page isn't available* and loads
|
||||||
|
nothing; the page never echoes the address back. This is what makes a disabled module's routes stop
|
||||||
|
behaving as active routes, with no new server state.
|
||||||
|
- `/settings…` and `/account…` are the platform's own pages and are never read as module pages. They
|
||||||
|
have no content yet, so they show *Page not found* for now.
|
||||||
|
- Everything below `/<module-id>/` belongs to that module's own pages. It is served by the API today
|
||||||
|
and shows *Page not found* in the shell until the module page contract exists. The shell never
|
||||||
|
guesses, and never shows a module's first page for an address the module never defined.
|
||||||
|
- One trailing slash is tolerated and corrected to the canonical spelling in place (`replace`, no new
|
||||||
|
entry), so what is bookmarked next is the one canonical address.
|
||||||
|
|
||||||
|
### Navigation is real links
|
||||||
|
|
||||||
|
Every navigation item is an `<a href>` with its canonical address and `aria-current="page"` when it
|
||||||
|
is open, marked by weight as well as colour. An ordinary click opens the page in place and adds one
|
||||||
|
history entry; a click with a modifier key or the middle button is left to the browser, so a page can
|
||||||
|
be opened in a new tab. Clicking the open page's own link adds no entry. The browser's Back and Forward
|
||||||
|
walk exactly the pages that were open.
|
||||||
|
|
||||||
|
### Page changes behave like page loads for assistive technology
|
||||||
|
|
||||||
|
After an in-place page change the shell moves focus to the page's `<main>` (named after the page) as
|
||||||
|
a full load would have; the first page shown after a refresh or sign-in is left alone. The entry point
|
||||||
|
names the browser tab after the open page ("Overview", the module's display name, or the error page's
|
||||||
|
title, and "Sign in" while signed out) — never "Ekklesia", in keeping with ADR 0046. The static page's
|
||||||
|
own `<title>` is unchanged here; it belongs to the branding work.
|
||||||
|
|
||||||
|
### Sign-in is unchanged, and still has no return URL
|
||||||
|
|
||||||
|
ADR 0034 holds exactly. The sign-in form still replaces the application in place at the current URL,
|
||||||
|
so a deep link opened while signed out lands on its own page once the person signs in — with no
|
||||||
|
`return`, `next`, or callback value, no redirect, and no script-driven navigation off the
|
||||||
|
Dashboard's own paths. The shell only ever moves between its own canonical paths. Emailed
|
||||||
|
verification and reset links still land on `/` with a query (ADR 0038).
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Module pages under a prefix (`/modules/<id>`).** Rejected: it exposes an implementation concept
|
||||||
|
in every address people see, and makes a module a second-class destination beside Settings. The
|
||||||
|
collision it avoided is handled instead by reserving the platform's names in Core.
|
||||||
|
- **Hash routing (`/#/signage`).** Rejected: every address is one page to the server, the proxy, and
|
||||||
|
any analytics; fragments never reach the server, so no server-side decision about a path is
|
||||||
|
possible; and DESIGN.md asks for real URLs.
|
||||||
|
- **Serving the page at any module-shaped root path.** Rejected: `/definitely-not-a-real-module`
|
||||||
|
would become a Dashboard page, and the server would answer `200` for every mistyped address. It
|
||||||
|
would remove the installed-module disclosure described above, at the cost of the server no longer
|
||||||
|
meaning anything by a `200`.
|
||||||
|
- **Serving only enabled, running modules' pages.** Rejected: a bookmark to a module an
|
||||||
|
administrator disabled would become a bare `404`, indistinguishable from a typo, instead of the
|
||||||
|
Dashboard explaining that the module is unavailable.
|
||||||
|
- **Listing modules in the proxy configuration.** Rejected: every install would need a proxy edit.
|
||||||
|
- **Reserving more names now** (`login`, `help`, `overview`, …) "just in case". Rejected: a reserved
|
||||||
|
name refuses a real module, so a name is reserved when the platform gets a route there and not
|
||||||
|
before.
|
||||||
|
- **Putting the reserved names in the manifest grammar.** Rejected for now: it is the Ekklesia host's
|
||||||
|
URL space, not a module property, and would need a public-contract release for no extra safety,
|
||||||
|
since discovery already refuses the module before it can be installed.
|
||||||
|
- **`react-router-dom` or another routing library.** Rejected for now; see above.
|
||||||
|
- **Keeping "open the first module" at `/`.** Rejected: DESIGN.md makes the Overview the landing page,
|
||||||
|
and an address that silently means "whatever happens to be first" is not a stable address.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A refresh, a bookmark, and a shared link land on the page that was open; Back and Forward walk the
|
||||||
|
Dashboard's own pages. Proven at three levels: the shell against an in-memory history; `apps/api`
|
||||||
|
serving the page at platform pages and installed modules' pages and the sanitized `404` everywhere
|
||||||
|
else; and the deploy smoke test, through the real proxy, checking platform deep links, unknown
|
||||||
|
names, a module's address staying served while the module is disabled and disappearing and
|
||||||
|
returning as it is removed and reinstalled,
|
||||||
|
and reloading a module's address in a real browser and requiring the same module to mount again.
|
||||||
|
- **The Dashboard opens on the Overview, not on the first module.** A person who used to land in
|
||||||
|
Signage now chooses it once from the navigation, and can bookmark `/signage`.
|
||||||
|
- **A module may not be called `account`, `api`, `assets`, `display`, or `settings`.** No known module
|
||||||
|
is; one that was would now fail discovery with `MODULE_ID_RESERVED` and must be renamed.
|
||||||
|
- **Operators with their own proxy must forward module-shaped top-level paths** (the matcher above),
|
||||||
|
or a refresh on any page other than `/` is the proxy's `404`. The deployment guide says so.
|
||||||
|
- `@ekklesia/shell` gains a `./dashboard-routes` subpath, and `apps/api` reads it. The surface remains
|
||||||
|
private; this is the same relationship `./dashboard-assets` already has.
|
||||||
|
- The navigation landmark is labelled "Dashboard" rather than "Modules", because it now contains the
|
||||||
|
Overview as well as modules.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
Each of these needs its own decision. This ADR reserves the addresses for them and decides none of
|
||||||
|
them:
|
||||||
|
|
||||||
|
- **A module's own pages** — how a module declares the pages that appear under it in the sidebar
|
||||||
|
(`/signage/playback`, `/signage/timelines`, `/signage/displays`), how it learns and changes its
|
||||||
|
current sub-path through the module UI SDK, and what an address below `/<module-id>/` means. The
|
||||||
|
server already serves the page there and the proxies already forward it, so that contract is a
|
||||||
|
manifest, SDK, and shell change with no further routing or deployment change. It is its own ADR.
|
||||||
|
- **Return to origin** — how a module or a Settings page links into another part of the Dashboard and
|
||||||
|
comes back to the exact workflow that sent the person there (DESIGN.md §2.7, §4.8). It must remain
|
||||||
|
a validated, same-origin route decision, as ADR 0034 requires, and is designed with the module page
|
||||||
|
contract rather than here.
|
||||||
|
- The Settings and Account pages themselves, the sidebar redesign, the mobile drawer, Overview cards,
|
||||||
|
breadcrumbs, and the installation's name in the browser tab.
|
||||||
@@ -44,7 +44,7 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
|
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
|
||||||
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
|
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
|
||||||
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
||||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted (session lifetime superseded by 0056)
|
||||||
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
||||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||||
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
||||||
@@ -64,3 +64,5 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted
|
- [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted
|
||||||
- [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted
|
- [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted
|
||||||
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
|
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
|
||||||
|
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
|
||||||
|
- [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted
|
||||||
|
|||||||
@@ -343,7 +343,9 @@ permission the inventory does; organizing requires the new platform permission
|
|||||||
shell renders that arrangement and computes none of it.
|
shell renders that arrangement and computes none of it.
|
||||||
|
|
||||||
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
|
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
|
||||||
Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment
|
Dashboard Home or widgets, no design system, and no client routing (client routing and an Overview
|
||||||
|
landing page have since landed; see
|
||||||
|
[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md)). A production CSP and deployment
|
||||||
for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
|
for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
|
||||||
pass 1 later supplies the host-owned sign-in flow described below.
|
pass 1 later supplies the host-owned sign-in flow described below.
|
||||||
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
||||||
|
|||||||
@@ -259,6 +259,16 @@ redirect target and performs no post-login navigation, so external and protocol-
|
|||||||
have no redirect sink in this pass. See
|
have no redirect sink in this pass. See
|
||||||
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
|
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
|
||||||
|
|
||||||
|
Since [ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md) that URL
|
||||||
|
is a real page address: the API serves the Dashboard page at `/`, at `/settings` and `/account`, and
|
||||||
|
at `/<module-id>` for every discovered module that declares a Dashboard UI (enabled or not) — each
|
||||||
|
bare and with everything below it — with the same bytes, validator, and headers as `/`. A deep link
|
||||||
|
opened while signed out therefore shows the sign-in form at that address and opens its own page once
|
||||||
|
the person signs in — still with no return value and no redirect. Any other path — a module that is
|
||||||
|
not installed, a display-only module, a look-alike such as `/settings-old` — remains the sanitized
|
||||||
|
`404`. Module ids `account`, `api`, `assets`, `display`, and `settings` are reserved for the platform
|
||||||
|
and refused by discovery (`MODULE_ID_RESERVED`).
|
||||||
|
|
||||||
### The four anonymous screens
|
### The four anonymous screens
|
||||||
|
|
||||||
The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
|
The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ Two builds of the same commit produce byte-identical tarballs, and CI proves it
|
|||||||
|
|
||||||
## Publishing the train
|
## Publishing the train
|
||||||
|
|
||||||
The registry is the Gitea npm registry under the `River_of_Glory_Church` owner:
|
The registry is the Gitea npm registry under the `Zayin` owner:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
https://git.zayin.net/api/packages/Zayin/npm/
|
||||||
```
|
```
|
||||||
|
|
||||||
| Who | May |
|
| Who | May |
|
||||||
@@ -272,7 +272,7 @@ Evidence that one train was published, not a second package catalog, and never a
|
|||||||
{
|
{
|
||||||
"publicationRecord": 1,
|
"publicationRecord": 1,
|
||||||
"packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" },
|
"packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" },
|
||||||
"registry": "https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/",
|
"registry": "https://git.zayin.net/api/packages/Zayin/npm/",
|
||||||
"finalTag": "latest",
|
"finalTag": "latest",
|
||||||
"completedAt": "<ISO time>",
|
"completedAt": "<ISO time>",
|
||||||
"packages": [
|
"packages": [
|
||||||
@@ -297,7 +297,7 @@ Evidence that one train was published, not a second package catalog, and never a
|
|||||||
|
|
||||||
- **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by
|
- **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by
|
||||||
the publish step. Nobody needs a token to read or install.
|
the publish step. Nobody needs a token to read or install.
|
||||||
- **The publisher** is a dedicated account, member only of a `River_of_Glory_Church` team whose one
|
- **The publisher** is a dedicated account, member only of a `Zayin` team whose one
|
||||||
permission is Packages: Write. Its token has only the `write:package` scope. Gitea's
|
permission is Packages: Write. Its token has only the `write:package` scope. Gitea's
|
||||||
`write:package` also allows deleting packages; the tool never does, and nothing else uses the
|
`write:package` also allows deleting packages; the tool never does, and nothing else uses the
|
||||||
token.
|
token.
|
||||||
@@ -319,7 +319,7 @@ guarantee:
|
|||||||
- **A published version is never deleted,** by anyone, for any reason short of a security incident.
|
- **A published version is never deleted,** by anyone, for any reason short of a security incident.
|
||||||
- **A version number is never reused with other bytes.** A version found on the registry with other
|
- **A version number is never reused with other bytes.** A version found on the registry with other
|
||||||
bytes is burned: investigate how it got there, and publish the train under a new version.
|
bytes is burned: investigate how it got there, and publish the train under a new version.
|
||||||
- **No package cleanup rule** may be configured for the `River_of_Glory_Church` owner.
|
- **No package cleanup rule** may be configured for the `Zayin` owner.
|
||||||
- **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests
|
- **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests
|
||||||
check that.
|
check that.
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ claim to be source that 0.1.2 never contained.
|
|||||||
Before it, an administrator of the Gitea instance:
|
Before it, an administrator of the Gitea instance:
|
||||||
|
|
||||||
1. creates the dedicated publisher account;
|
1. creates the dedicated publisher account;
|
||||||
2. creates a `River_of_Glory_Church` team, for example `package-publishers`, with only the Packages
|
2. creates a `Zayin` team, for example `package-publishers`, with only the Packages
|
||||||
unit at Write, and adds the account to it;
|
unit at Write, and adds the account to it;
|
||||||
3. creates a token for the account with only `write:package`, and stores it as the repository
|
3. creates a token for the account with only `write:package`, and stores it as the repository
|
||||||
Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`;
|
Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ promises no API stability beyond the pre-1.0 SemVer policy. Only Ekklesia's rele
|
|||||||
the registry in your repository's committed `.npmrc`; that is all the configuration there is:
|
the registry in your repository's committed `.npmrc`; that is all the configuration there is:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
@ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
@ekklesia:registry=https://git.zayin.net/api/packages/Zayin/npm/
|
||||||
```
|
```
|
||||||
|
|
||||||
Then the module builds and packages like any other:
|
Then the module builds and packages like any other:
|
||||||
@@ -535,7 +535,12 @@ without executing module code:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Module, capability, and permission identifiers are lowercase and namespaced. A provided capability
|
Module, capability, and permission identifiers are lowercase and namespaced. A module with a
|
||||||
|
Dashboard UI is addressed at `/<module-id>` — `/calendar` for this one
|
||||||
|
([ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)) — in the same
|
||||||
|
top-level URL space as the platform's own pages, so a few ids are **reserved** and Core refuses a
|
||||||
|
module that declares one (`MODULE_ID_RESERVED`, reported by discovery, `pnpm modules:validate`, and
|
||||||
|
package verification): `account`, `api`, `assets`, `display`, and `settings`. A provided capability
|
||||||
has its own exact semantic version because its public contract can evolve independently from the
|
has its own exact semantic version because its public contract can evolve independently from the
|
||||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||||
may be absent; if it is installed, its capability version must be compatible.
|
may be absent; if it is installed, its capability version must be compatible.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ekklesia",
|
"name": "ekklesia",
|
||||||
"version": "0.2.3",
|
"version": "0.2.5",
|
||||||
"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.5",
|
||||||
"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.5",
|
||||||
"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.5",
|
||||||
"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.5",
|
||||||
"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.5",
|
||||||
"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.5",
|
||||||
"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.5",
|
||||||
"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.5",
|
||||||
"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.5",
|
||||||
"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.5",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
+14
-2
@@ -5,6 +5,11 @@
|
|||||||
# bash scripts/ci-sweep.sh --stale [--max-age-seconds N] any CI stack older than N (default 3 h)
|
# bash scripts/ci-sweep.sh --stale [--max-age-seconds N] any CI stack older than N (default 3 h)
|
||||||
# bash scripts/ci-sweep.sh --job this job's own stacks, at any age
|
# bash scripts/ci-sweep.sh --job this job's own stacks, at any age
|
||||||
#
|
#
|
||||||
|
# `--stale --prefix P` narrows the sweep to stacks whose names begin with P, which must itself begin
|
||||||
|
# with the CI prefix: it can only ever narrow a sweep, never widen one. scripts/test-ci-sweep.sh
|
||||||
|
# confines its short thresholds with it, because the daemon is shared and another job's live stack
|
||||||
|
# may be on it right now.
|
||||||
|
#
|
||||||
# Every CI script already removes its own stack in an EXIT trap, but a trap never runs when the runner
|
# Every CI script already removes its own stack in an EXIT trap, but a trap never runs when the runner
|
||||||
# kills the job container: a cancelled or superseded job (the server honours `concurrency`), or
|
# kills the job container: a cancelled or superseded job (the server honours `concurrency`), or
|
||||||
# the runner's own time limit. So each workflow job that starts stacks ends with `--job` in an
|
# the runner's own time limit. So each workflow job that starts stacks ends with `--job` in an
|
||||||
@@ -31,9 +36,10 @@ source "$repository_root/scripts/lib/ci-project.sh"
|
|||||||
compose_label="com.docker.compose.project"
|
compose_label="com.docker.compose.project"
|
||||||
mode=""
|
mode=""
|
||||||
max_age_seconds="${EKKLESIA_CI_SWEEP_MAX_AGE_SECONDS:-10800}"
|
max_age_seconds="${EKKLESIA_CI_SWEEP_MAX_AGE_SECONDS:-10800}"
|
||||||
|
narrow_prefix=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] | --job" >&2
|
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] [--prefix P] | --job" >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +54,17 @@ while (($# > 0)); do
|
|||||||
max_age_seconds="$2"
|
max_age_seconds="$2"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--prefix)
|
||||||
|
[[ "${2:-}" == "$EKKLESIA_CI_PREFIX"?* ]] || usage
|
||||||
|
narrow_prefix="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
[[ -n "$mode" ]] || usage
|
[[ -n "$mode" ]] || usage
|
||||||
|
[[ -z "$narrow_prefix" || "$mode" == stale ]] || usage
|
||||||
|
|
||||||
if [[ "$mode" == job ]]; then
|
if [[ "$mode" == job ]]; then
|
||||||
[[ -n "${GITHUB_RUN_ID:-}" ]] || {
|
[[ -n "${GITHUB_RUN_ID:-}" ]] || {
|
||||||
@@ -62,7 +74,7 @@ if [[ "$mode" == job ]]; then
|
|||||||
prefix="$(ci_job_prefix)"
|
prefix="$(ci_job_prefix)"
|
||||||
cutoff=""
|
cutoff=""
|
||||||
else
|
else
|
||||||
prefix="$EKKLESIA_CI_PREFIX"
|
prefix="${narrow_prefix:-$EKKLESIA_CI_PREFIX}"
|
||||||
cutoff="$(($(date +%s) - max_age_seconds))"
|
cutoff="$(($(date +%s) - max_age_seconds))"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,9 @@ check(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// --- A module UI, imported from the API's origin -------------------------------------------------
|
// --- A module UI, imported from the API's origin -------------------------------------------------
|
||||||
const moduleButton = "document.querySelector('[data-testid^=\"select-\"]')";
|
// The first module link — never the Overview, which is the Dashboard's own landing page (ADR 0057).
|
||||||
|
const moduleButton =
|
||||||
|
'document.querySelector(\'[data-testid^="select-"]:not([data-testid="select-overview"])\')';
|
||||||
const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
|
const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
|
||||||
if (hasModule) {
|
if (hasModule) {
|
||||||
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
|
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
|
||||||
@@ -224,6 +226,20 @@ if (hasModule) {
|
|||||||
streams.every((response) => response.status === 200),
|
streams.every((response) => response.status === 200),
|
||||||
`every realtime stream it opened was accepted (${streams.length} opened)`,
|
`every realtime stream it opened was accepted (${streams.length} opened)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A refresh stays on the module's own page: the proxy forwards the deep link, the API serves the
|
||||||
|
// Dashboard page there, and the shell mounts the same module again (ADR 0057).
|
||||||
|
const moduleUrl = await evaluate("location.href");
|
||||||
|
check(
|
||||||
|
moduleUrl === `${dashboardOrigin}/${moduleId}`,
|
||||||
|
"opening the module put its own address in the address bar",
|
||||||
|
);
|
||||||
|
await navigate(moduleUrl);
|
||||||
|
const remounted = await waitFor(
|
||||||
|
`location.href === ${JSON.stringify(moduleUrl)} && ${selector(`select-${moduleId}`)}?.getAttribute("aria-current") === "page" && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
|
||||||
|
"the module UI to mount again after a refresh",
|
||||||
|
);
|
||||||
|
check(remounted, `refreshing ${moduleUrl} reopens the ${moduleId} module`);
|
||||||
}
|
}
|
||||||
// A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a
|
// A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a
|
||||||
// module unmounting, a navigation) is not.
|
// module unmounting, a navigation) is not.
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -84,6 +88,27 @@ async function edgeChecks() {
|
|||||||
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
|
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). No module
|
||||||
|
// is installed yet at this point; a module's own address is reloaded in a real browser later.
|
||||||
|
for (const deepLink of ["/settings", "/settings/displays/add", "/account"]) {
|
||||||
|
const deepPage = await fetch(`${dashboardOrigin}${deepLink}`);
|
||||||
|
check(
|
||||||
|
deepPage.status === 200 &&
|
||||||
|
deepPage.headers.get("etag") === page.headers.get("etag") &&
|
||||||
|
(await deepPage.text()) === html,
|
||||||
|
`GET ${deepLink} through the proxy is the same Dashboard page`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A top-level name shaped like a module id reaches the API, which knows the installed modules and
|
||||||
|
// answers anything that is not a Dashboard page with its sanitized 404 — never the page.
|
||||||
|
for (const unknown of ["/definitely-not-a-real-module", "/settings-old", "/modules/example"]) {
|
||||||
|
const response = await fetch(`${dashboardOrigin}${unknown}`);
|
||||||
|
check(
|
||||||
|
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
|
||||||
|
`GET ${unknown} through the proxy is the API's sanitized 404, not a Dashboard page`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
|
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
|
||||||
await script.arrayBuffer();
|
await script.arrayBuffer();
|
||||||
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
|
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
|
||||||
@@ -148,6 +173,10 @@ async function splitRoutingChecks() {
|
|||||||
"the Dashboard hostname refuses /display/*",
|
"the Dashboard hostname refuses /display/*",
|
||||||
);
|
);
|
||||||
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /");
|
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /");
|
||||||
|
check(
|
||||||
|
await refusedByProxy(`${apiOrigin}/settings/displays`),
|
||||||
|
"the API hostname serves no Dashboard page either",
|
||||||
|
);
|
||||||
check(
|
check(
|
||||||
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
|
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
|
||||||
"the API hostname refuses the Dashboard's script",
|
"the API hostname refuses the Dashboard's script",
|
||||||
@@ -462,37 +491,70 @@ 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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The module's own Dashboard address (ADR 0057) exists exactly while the module is installed with a
|
||||||
|
// Dashboard UI — disabled or not: the Dashboard page when it is, the API's sanitized 404 when it is
|
||||||
|
// not. A disabled module is not running, but its address still opens the Dashboard.
|
||||||
|
const pageExpectation =
|
||||||
|
optional("SMOKE_MODULE_PAGE") || (expectation === "absent" ? "absent" : "served");
|
||||||
|
const modulePage = await fetch(`${dashboardOrigin}/${moduleId}`);
|
||||||
|
const modulePageBody = await modulePage.text();
|
||||||
|
if (pageExpectation === "absent") {
|
||||||
|
check(
|
||||||
|
modulePage.status === 404 && modulePageBody === '{"code":"NOT_FOUND"}',
|
||||||
|
`GET /${moduleId} is the sanitized 404 while the module is not installed`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
check(
|
||||||
|
modulePage.status === 200 && modulePageBody.includes('id="ekklesia-host"'),
|
||||||
|
`GET /${moduleId} through the proxy is the Dashboard page while the module is installed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleId === "example") {
|
||||||
|
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
|
||||||
|
if (expectation === "absent") {
|
||||||
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: "{}",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
// stream-failure the first realtime connection is refused: is realtime re-established?
|
// stream-failure the first realtime connection is refused: is realtime re-established?
|
||||||
// caching-probe can a display on the API origin store storage-origin images in Cache Storage?
|
// caching-probe can a display on the API origin store storage-origin images in Cache Storage?
|
||||||
// stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline
|
// stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline
|
||||||
// revoke revoke the paired kiosk while its display is open
|
// revoke revoke the paired kiosk while its display is open, then pair it again
|
||||||
//
|
//
|
||||||
// Every DevTools command and every page-side request has its own deadline, and a step has an overall
|
// Every DevTools command and every page-side request has its own deadline, and a step has an overall
|
||||||
// budget. A call that never answers fails the step with a line naming that exact call, because a call
|
// budget. A call that never answers fails the step with a line naming that exact call, because a call
|
||||||
@@ -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(
|
||||||
@@ -951,6 +976,32 @@ async function run() {
|
|||||||
result.stopped,
|
result.stopped,
|
||||||
`a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`,
|
`a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`,
|
||||||
);
|
);
|
||||||
|
// Revocation is recoverable: the same screen pairs again and resumes, keeping its place in the
|
||||||
|
// module's roster. It also leaves the kiosk paired for the steps that run after this one.
|
||||||
|
const rosterBefore = moduleId === "signage" ? (await signageViewers(owner.page)).length : 0;
|
||||||
|
if (await page.waitFor(find("display-pairing-code"), "a fresh pairing code after revocation")) {
|
||||||
|
const code = (
|
||||||
|
await page.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code")
|
||||||
|
).replace(/\D/gu, "");
|
||||||
|
const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", {
|
||||||
|
code,
|
||||||
|
deviceName: "Repro kiosk",
|
||||||
|
});
|
||||||
|
check(approved.status === 200, "the owner approves the revoked screen's new code");
|
||||||
|
check(
|
||||||
|
await waitLive(page, "the re-paired display to go live"),
|
||||||
|
"a revoked screen is paired again and resumes without a reload",
|
||||||
|
);
|
||||||
|
if (moduleId === "signage") {
|
||||||
|
const rosterAfter = (await signageViewers(owner.page)).length;
|
||||||
|
check(
|
||||||
|
rosterAfter === rosterBefore,
|
||||||
|
`re-pairing keeps the screen's existing roster entry (${rosterBefore} before, ${rosterAfter} after)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
check(false, "a revoked screen shows a fresh pairing code");
|
||||||
|
}
|
||||||
await page.close();
|
await page.close();
|
||||||
kiosk.close();
|
kiosk.close();
|
||||||
await finishOwner(owner);
|
await finishOwner(owner);
|
||||||
|
|||||||
+164
-19
@@ -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,25 @@ restart_api() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
|
# recreate_api removes the application container, not its installation-owned module bind mount or
|
||||||
|
# database volumes, then creates a fresh container from the same generic image.
|
||||||
|
recreate_api() {
|
||||||
|
"${compose[@]}" stop api >/dev/null 2>&1 || fail "the API did not stop for recreation."
|
||||||
|
"${compose[@]}" rm --force api >/dev/null 2>&1 || fail "the API container was not removed."
|
||||||
|
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 api 2>&1)"; then
|
||||||
|
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||||
|
fail "the recreated API did not become healthy."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# module_check <expectation> [page]: "absent", or the exact JSON array of values `example` must
|
||||||
|
# return. `page` is whether its Dashboard address is served (ADR 0057): by default, exactly when the
|
||||||
|
# module is running; "served" for a module that is installed but disabled, whose address still opens
|
||||||
|
# the Dashboard so it can say the module is unavailable.
|
||||||
module_check() {
|
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_PAGE="${2:-}" \
|
||||||
|
-e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \
|
||||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
|
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,21 +563,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 +603,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 +613,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 +625,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 +663,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 +705,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 served || fail "the disabled module was still reported as running."
|
||||||
|
module_storage "test -d /modules/${module_id}" ||
|
||||||
|
fail "disabling the module removed its installed package."
|
||||||
|
restart_api "${compose[@]}" run --rm --no-deps migrate node \
|
||||||
|
core/dist/cli/set-module-enabled.js --enable --module "$module_id"
|
||||||
|
if [[ "$module_id" == example ]]; then
|
||||||
|
module_check '["Hello, kept"]' || fail "the re-enabled module did not return with its data."
|
||||||
|
else
|
||||||
|
module_check present || fail "the re-enabled module did not return."
|
||||||
|
fi
|
||||||
|
run_browser_checks || fail "the re-enabled module UI did not return."
|
||||||
|
|
||||||
step "Removing the module from module storage and restarting: Core runs, its data stays"
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
#
|
#
|
||||||
# bash scripts/test-ci-sweep.sh
|
# bash scripts/test-ci-sweep.sh
|
||||||
#
|
#
|
||||||
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, so
|
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, and
|
||||||
# it can never touch a real stack; and it removes all of them on exit, whatever happens.
|
# every sweep it runs is confined to those identifiers, so it can never touch a real stack: the
|
||||||
|
# runner's daemon is shared, and another job's live stack may be on it right now. It removes all of
|
||||||
|
# its own resources on exit, whatever happens.
|
||||||
|
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
@@ -91,10 +93,14 @@ source "$repository_root/scripts/lib/ci-project.sh"
|
|||||||
# Fake run identifiers: nothing here can match a real run's stacks.
|
# Fake run identifiers: nothing here can match a real run's stacks.
|
||||||
export GITHUB_RUN_ID="sweeptest${nonce}"
|
export GITHUB_RUN_ID="sweeptest${nonce}"
|
||||||
export GITHUB_RUN_ATTEMPT="1"
|
export GITHUB_RUN_ATTEMPT="1"
|
||||||
|
# Every stack this test sweeps begins with this; nothing it sweeps reaches past it.
|
||||||
|
own_run="$(ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID}-")"
|
||||||
mine="$(EKKLESIA_CI_JOB_KEY=alpha ci_project_name mine)"
|
mine="$(EKKLESIA_CI_JOB_KEY=alpha ci_project_name mine)"
|
||||||
sibling="$(EKKLESIA_CI_JOB_KEY=beta ci_project_name sibling)"
|
sibling="$(EKKLESIA_CI_JOB_KEY=beta ci_project_name sibling)"
|
||||||
foreign="ekklesia-sweep-control-${nonce}"
|
foreign="ekklesia-sweep-control-${nonce}"
|
||||||
for project in "$mine" "$sibling" "$foreign"; do
|
# Stands in for another run's live stack on the shared daemon — a module's integration job, say.
|
||||||
|
bystander="$(GITHUB_RUN_ID="bystander${nonce}" EKKLESIA_CI_JOB_KEY=gamma ci_project_name live)"
|
||||||
|
for project in "$mine" "$sibling" "$foreign" "$bystander"; do
|
||||||
make_stack "$project"
|
make_stack "$project"
|
||||||
done
|
done
|
||||||
# A job container connected to this job's stack network, exactly as compose-job-network.sh does.
|
# A job container connected to this job's stack network, exactly as compose-job-network.sh does.
|
||||||
@@ -115,12 +121,19 @@ check '! env -u GITHUB_RUN_ID bash scripts/ci-sweep.sh --job >/dev/null 2>&1' \
|
|||||||
"--job without GITHUB_RUN_ID fails"
|
"--job without GITHUB_RUN_ID fails"
|
||||||
|
|
||||||
echo "--- --stale removes only CI stacks older than its threshold"
|
echo "--- --stale removes only CI stacks older than its threshold"
|
||||||
bash scripts/ci-sweep.sh --stale
|
bash scripts/ci-sweep.sh --stale --prefix "$own_run"
|
||||||
check 'project_exists "$sibling"' "a young CI stack survives the default threshold"
|
check 'project_exists "$sibling"' "a young CI stack survives the default threshold"
|
||||||
bash scripts/ci-sweep.sh --stale --max-age-seconds 0
|
bash scripts/ci-sweep.sh --stale --max-age-seconds 0 --prefix "$own_run"
|
||||||
check '! project_exists "$sibling"' "a CI stack older than the threshold is removed"
|
check '! project_exists "$sibling"' "a CI stack older than the threshold is removed"
|
||||||
check '! staging_exists "$sibling"' "its staging directory too"
|
check '! staging_exists "$sibling"' "its staging directory too"
|
||||||
check 'project_exists "$foreign"' "a stack outside the CI prefix is never removed, at any age"
|
check 'project_exists "$foreign"' "a stack outside the CI prefix is never removed, at any age"
|
||||||
|
check 'project_exists "$bystander"' \
|
||||||
|
"another run's stack is never removed by a narrowed sweep, even past the threshold"
|
||||||
|
echo "--- --prefix only ever narrows the sweep"
|
||||||
|
check '! bash scripts/ci-sweep.sh --stale --prefix "ekklesia-" >/dev/null 2>&1' \
|
||||||
|
"a prefix outside the CI prefix is refused"
|
||||||
|
check '! bash scripts/ci-sweep.sh --job --prefix "$own_run" >/dev/null 2>&1' \
|
||||||
|
"--prefix with --job is refused"
|
||||||
|
|
||||||
if ((failures > 0)); then
|
if ((failures > 0)); then
|
||||||
echo "test-ci-sweep: ${failures} check(s) failed." >&2
|
echo "test-ci-sweep: ${failures} check(s) failed." >&2
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ for user in publisher outsider; do
|
|||||||
password_variable="${user}_password"
|
password_variable="${user}_password"
|
||||||
api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null
|
api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null
|
||||||
done
|
done
|
||||||
owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
|
owners=(Zayin ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
|
||||||
for owner in "${owners[@]}"; do
|
for owner in "${owners[@]}"; do
|
||||||
api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null
|
api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null
|
||||||
team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' |
|
team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/shell",
|
"name": "@ekklesia/shell",
|
||||||
"version": "0.2.3",
|
"version": "0.2.5",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -16,6 +16,10 @@
|
|||||||
"types": "./dist/dashboard-assets.d.ts",
|
"types": "./dist/dashboard-assets.d.ts",
|
||||||
"import": "./dist/dashboard-assets.js"
|
"import": "./dist/dashboard-assets.js"
|
||||||
},
|
},
|
||||||
|
"./dashboard-routes": {
|
||||||
|
"types": "./dist/dashboard-routes.d.ts",
|
||||||
|
"import": "./dist/dashboard-routes.js"
|
||||||
|
},
|
||||||
"./display-assets": {
|
"./display-assets": {
|
||||||
"types": "./dist/display-assets.d.ts",
|
"types": "./dist/display-assets.d.ts",
|
||||||
"import": "./dist/display-assets.js"
|
"import": "./dist/display-assets.js"
|
||||||
|
|||||||
@@ -89,7 +89,13 @@ await copyFile(
|
|||||||
if (serve) {
|
if (serve) {
|
||||||
const context = await esbuild.context(buildOptions);
|
const context = await esbuild.context(buildOptions);
|
||||||
await context.watch();
|
await context.watch();
|
||||||
const { host, port } = await context.serve({ servedir: outputDirectory });
|
// Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as
|
||||||
|
// `/example` reaches the shell's router here as it does from `apps/api` (ADR 0057).
|
||||||
|
// Development only: the API serves the page only at its platform pages and installed modules'.
|
||||||
|
const { host, port } = await context.serve({
|
||||||
|
fallback: path.join(outputDirectory, "index.html"),
|
||||||
|
servedir: outputDirectory,
|
||||||
|
});
|
||||||
console.log(`Ekklesia shell (development) on http://${host}:${port}`);
|
console.log(`Ekklesia shell (development) on http://${host}:${port}`);
|
||||||
console.log(
|
console.log(
|
||||||
apiBaseUrl === ""
|
apiBaseUrl === ""
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { designToken } from "@ekklesia/design-tokens";
|
||||||
|
import type { CSSProperties, MouseEvent, ReactElement, ReactNode } from "react";
|
||||||
|
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
|
||||||
|
import type { HostHistory } from "./host-history.js";
|
||||||
|
|
||||||
|
export interface DashboardLinkProps {
|
||||||
|
readonly history: HostHistory;
|
||||||
|
readonly to: DashboardDestination;
|
||||||
|
readonly children: ReactNode;
|
||||||
|
/** Marks the link as the page currently open, for assistive technology and visually. */
|
||||||
|
readonly current?: boolean;
|
||||||
|
readonly variant?: "navigation" | "action";
|
||||||
|
readonly "data-testid"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClass = {
|
||||||
|
action: "ek-button ek-focusable ek-button--secondary",
|
||||||
|
navigation: "ek-button ek-focusable ek-button--quiet",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const navigationStyle: CSSProperties = {
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
textDecoration: "none",
|
||||||
|
width: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The open page is shown by weight as well as colour, so it never depends on colour alone. */
|
||||||
|
const currentStyle: CSSProperties = {
|
||||||
|
...navigationStyle,
|
||||||
|
background: designToken("state.selected-background"),
|
||||||
|
color: designToken("state.selected-foreground"),
|
||||||
|
fontWeight: designToken("typography.weight-bold"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
|
||||||
|
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
|
||||||
|
return (
|
||||||
|
event.defaultPrevented ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.shiftKey ||
|
||||||
|
event.altKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A link to a Dashboard page, as a real `<a href>` (ADR 0057).
|
||||||
|
*
|
||||||
|
* A real link rather than a button, because it is one: it can be opened in a new tab, copied, and
|
||||||
|
* bookmarked, and assistive technology announces it as a link. An ordinary click opens the page in
|
||||||
|
* place through the shell's history instead of reloading the document; every other click — with a
|
||||||
|
* modifier key, or the middle button — is left to the browser.
|
||||||
|
*/
|
||||||
|
export function DashboardLink(props: DashboardLinkProps): ReactElement {
|
||||||
|
const href = dashboardPath(props.to);
|
||||||
|
const variant = props.variant ?? "navigation";
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-current={props.current ? "page" : undefined}
|
||||||
|
className={variantClass[variant]}
|
||||||
|
data-testid={props["data-testid"]}
|
||||||
|
href={href}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (isBrowserHandledClick(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
props.history.push(href);
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
variant === "action"
|
||||||
|
? { textDecoration: "none" }
|
||||||
|
: props.current
|
||||||
|
? currentStyle
|
||||||
|
: navigationStyle
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,7 +1,15 @@
|
|||||||
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 { Button } from "@ekklesia/ui";
|
import { Button } from "@ekklesia/ui";
|
||||||
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
import {
|
||||||
|
type ReactElement,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
HostAuthError,
|
HostAuthError,
|
||||||
type HostAuthLanding,
|
type HostAuthLanding,
|
||||||
@@ -12,6 +20,7 @@ import {
|
|||||||
signInWithEmail,
|
signInWithEmail,
|
||||||
signOutHost,
|
signOutHost,
|
||||||
} from "../auth-protocol.js";
|
} from "../auth-protocol.js";
|
||||||
|
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
|
||||||
import {
|
import {
|
||||||
bootstrapHost,
|
bootstrapHost,
|
||||||
type DashboardNavigationEntry,
|
type DashboardNavigationEntry,
|
||||||
@@ -20,8 +29,11 @@ import {
|
|||||||
HostProtocolError,
|
HostProtocolError,
|
||||||
loadHostPrincipal,
|
loadHostPrincipal,
|
||||||
} from "../host-protocol.js";
|
} from "../host-protocol.js";
|
||||||
|
import { DashboardLink } from "./DashboardLink.js";
|
||||||
import { DevicePanel } from "./DevicePanel.js";
|
import { DevicePanel } from "./DevicePanel.js";
|
||||||
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
|
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
|
||||||
|
import type { HostHistory } from "./host-history.js";
|
||||||
|
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
|
||||||
import { LoginForm } from "./LoginForm.js";
|
import { LoginForm } from "./LoginForm.js";
|
||||||
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
||||||
import { ResetPasswordForm } from "./ResetPasswordForm.js";
|
import { ResetPasswordForm } from "./ResetPasswordForm.js";
|
||||||
@@ -101,6 +113,15 @@ export interface HostAppProps {
|
|||||||
* so there is nothing an open redirect could use.
|
* so there is nothing an open redirect could use.
|
||||||
*/
|
*/
|
||||||
readonly authLanding?: HostAuthLanding;
|
readonly authLanding?: HostAuthLanding;
|
||||||
|
/**
|
||||||
|
* The address bar (ADR 0057). The current path decides which page is open, so a refresh, a
|
||||||
|
* bookmark, and Back and Forward all land where the person was. The entry point passes the
|
||||||
|
* browser's; tests pass an in-memory one. Must be a stable reference.
|
||||||
|
*/
|
||||||
|
readonly history: HostHistory;
|
||||||
|
/** Names the browser tab after the open page. Supplied by the entry point, which owns `document`;
|
||||||
|
* left unset, the shell names nothing. */
|
||||||
|
readonly setDocumentTitle?: (title: string) => void;
|
||||||
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
|
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
|
||||||
readonly importModule?: ModuleUiImporter;
|
readonly importModule?: ModuleUiImporter;
|
||||||
}
|
}
|
||||||
@@ -167,6 +188,8 @@ const shellStyles = {
|
|||||||
main: {
|
main: {
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
// A programmatic focus target after a page change (never in the tab order), not a control.
|
||||||
|
outline: "none",
|
||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
padding: designToken("spacing.xl"),
|
padding: designToken("spacing.xl"),
|
||||||
},
|
},
|
||||||
@@ -194,25 +217,30 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModuleButton(props: {
|
function ModuleLink(props: {
|
||||||
readonly entry: DashboardNavigationEntry;
|
readonly entry: DashboardNavigationEntry;
|
||||||
readonly selected: boolean;
|
readonly history: HostHistory;
|
||||||
readonly onSelect: () => void;
|
readonly route: DashboardRoute;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
|
const moduleId = props.entry.descriptor.id;
|
||||||
return (
|
return (
|
||||||
<Button
|
<DashboardLink
|
||||||
aria-current={props.selected ? "page" : undefined}
|
current={props.route.kind === "module" && props.route.moduleId === moduleId}
|
||||||
aria-pressed={props.selected}
|
data-testid={`select-${moduleId}`}
|
||||||
data-testid={`select-${props.entry.descriptor.id}`}
|
history={props.history}
|
||||||
onClick={props.onSelect}
|
to={{ kind: "module", moduleId }}
|
||||||
style={{ justifyContent: "flex-start", width: "100%" }}
|
|
||||||
variant="quiet"
|
|
||||||
>
|
>
|
||||||
{props.entry.name}
|
{props.entry.name}
|
||||||
</Button>
|
</DashboardLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
|
||||||
|
* not a page change. */
|
||||||
|
function routeKey(route: DashboardRoute): string {
|
||||||
|
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
|
||||||
|
}
|
||||||
|
|
||||||
function HostMessage(props: {
|
function HostMessage(props: {
|
||||||
readonly children: string;
|
readonly children: string;
|
||||||
readonly testId: string;
|
readonly testId: string;
|
||||||
@@ -265,21 +293,27 @@ async function resolveBootstrapState(
|
|||||||
/**
|
/**
|
||||||
* The Ekklesia Dashboard shell and its host-owned authentication boundary.
|
* The Ekklesia Dashboard shell and its host-owned authentication boundary.
|
||||||
*
|
*
|
||||||
* The login form replaces the protected application in place. There is no redirect parameter, URL
|
* The login form replaces the protected application in place. There is no redirect parameter,
|
||||||
* parser, location assignment, or client router in this shell, so the current internal Dashboard URL
|
* return value, or location assignment in this shell, so the current internal Dashboard URL simply
|
||||||
* simply remains the current URL through sign-in and no external/protocol-relative return value can
|
* remains the current URL through sign-in — a deep link opens on its own page once the person has
|
||||||
* become a navigation sink. Modules receive the same five-field context after authentication; they
|
* signed in — and no external/protocol-relative value can become a navigation sink. The shell only
|
||||||
* neither render platform login nor receive sign-in/sign-out controls.
|
* ever moves between its own paths (ADR 0057). Modules receive the same context after
|
||||||
|
* authentication; they neither render platform login nor receive sign-in/sign-out controls.
|
||||||
*/
|
*/
|
||||||
export function HostApp(props: HostAppProps): ReactElement {
|
export function HostApp(props: HostAppProps): ReactElement {
|
||||||
const [state, setState] = useState<BootstrapState>({ status: "loading" });
|
const [state, setState] = useState<BootstrapState>({ status: "loading" });
|
||||||
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
|
|
||||||
const [bootstrapAttempt, setBootstrapAttempt] = useState(0);
|
const [bootstrapAttempt, setBootstrapAttempt] = useState(0);
|
||||||
const activeTransitionRef = useRef<AbortController | undefined>(undefined);
|
const activeTransitionRef = useRef<AbortController | undefined>(undefined);
|
||||||
const revalidationRef = useRef<AbortController | undefined>(undefined);
|
const revalidationRef = useRef<AbortController | undefined>(undefined);
|
||||||
const generationRef = useRef(0);
|
const generationRef = useRef(0);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const { config, importModule } = props;
|
const mainRef = useRef<HTMLElement>(null);
|
||||||
|
const shownRouteRef = useRef<string | undefined>(undefined);
|
||||||
|
const { config, history, importModule, setDocumentTitle } = props;
|
||||||
|
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
|
||||||
|
const readPathname = useCallback(() => history.pathname(), [history]);
|
||||||
|
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
|
||||||
|
const route = useMemo(() => parseDashboardPath(pathname), [pathname]);
|
||||||
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
|
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
|
||||||
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
|
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
|
||||||
// cannot resurrect a screen the person has left.
|
// cannot resurrect a screen the person has left.
|
||||||
@@ -306,14 +340,16 @@ export function HostApp(props: HostAppProps): ReactElement {
|
|||||||
|
|
||||||
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
|
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
|
||||||
setState(resolved);
|
setState(resolved);
|
||||||
if (resolved.status === "ready") {
|
|
||||||
const first = allEntries(resolved.bootstrap)[0];
|
|
||||||
setSelectedKey(first ? moduleKey(first) : undefined);
|
|
||||||
} else {
|
|
||||||
setSelectedKey(undefined);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// A non-canonical spelling of a real page (`/example/`) is corrected in place, adding no
|
||||||
|
// history entry, so what is bookmarked or shared next is the one canonical address.
|
||||||
|
useEffect(() => {
|
||||||
|
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
|
||||||
|
history.replace(dashboardPath(route));
|
||||||
|
}
|
||||||
|
}, [history, pathname, route]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -329,7 +365,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
|||||||
void bootstrapAttempt;
|
void bootstrapAttempt;
|
||||||
const { controller, generation } = beginTransition();
|
const { controller, generation } = beginTransition();
|
||||||
setState({ status: "loading" });
|
setState({ status: "loading" });
|
||||||
setSelectedKey(undefined);
|
|
||||||
void resolveBootstrapState(config, controller.signal).then((resolved) => {
|
void resolveBootstrapState(config, controller.signal).then((resolved) => {
|
||||||
if (!isCurrent(controller, generation)) {
|
if (!isCurrent(controller, generation)) {
|
||||||
return;
|
return;
|
||||||
@@ -381,7 +416,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
|||||||
const { controller, generation } = beginTransition();
|
const { controller, generation } = beginTransition();
|
||||||
// The authenticated subtree disappears before the request starts. That synchronously schedules
|
// The authenticated subtree disappears before the request starts. That synchronously schedules
|
||||||
// ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions.
|
// ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions.
|
||||||
setSelectedKey(undefined);
|
|
||||||
setState({ status: "signing-out" });
|
setState({ status: "signing-out" });
|
||||||
try {
|
try {
|
||||||
await signOutHost(config, controller.signal);
|
await signOutHost(config, controller.signal);
|
||||||
@@ -435,7 +469,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
|||||||
const transition = beginTransition();
|
const transition = beginTransition();
|
||||||
transition.controller.abort();
|
transition.controller.abort();
|
||||||
activeTransitionRef.current = undefined;
|
activeTransitionRef.current = undefined;
|
||||||
setSelectedKey(undefined);
|
|
||||||
setAuthView({ kind: "sign-in" });
|
setAuthView({ kind: "sign-in" });
|
||||||
setState({ status: "anonymous" });
|
setState({ status: "anonymous" });
|
||||||
}
|
}
|
||||||
@@ -451,7 +484,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
|||||||
const transition = beginTransition();
|
const transition = beginTransition();
|
||||||
transition.controller.abort();
|
transition.controller.abort();
|
||||||
activeTransitionRef.current = undefined;
|
activeTransitionRef.current = undefined;
|
||||||
setSelectedKey(undefined);
|
|
||||||
console.error("[ekklesia] session revalidation failed");
|
console.error("[ekklesia] session revalidation failed");
|
||||||
setState({ status: "failed" });
|
setState({ status: "failed" });
|
||||||
})
|
})
|
||||||
@@ -464,6 +496,41 @@ export function HostApp(props: HostAppProps): ReactElement {
|
|||||||
[beginTransition, config],
|
[beginTransition, config],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openEntry =
|
||||||
|
state.status === "ready" && route.kind === "module"
|
||||||
|
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
|
||||||
|
: undefined;
|
||||||
|
const pageTitle =
|
||||||
|
state.status !== "ready"
|
||||||
|
? undefined
|
||||||
|
: route.kind === "overview"
|
||||||
|
? "Overview"
|
||||||
|
: route.kind === "module"
|
||||||
|
? (openEntry?.name ?? "This page isn’t available")
|
||||||
|
: "Page not found";
|
||||||
|
const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (documentTitle !== undefined) {
|
||||||
|
setDocumentTitle?.(documentTitle);
|
||||||
|
}
|
||||||
|
}, [documentTitle, setDocumentTitle]);
|
||||||
|
|
||||||
|
// Moving to another page moves keyboard and screen-reader focus to the new page's content, as a
|
||||||
|
// full page load would have. The first page shown after sign-in or a refresh is left alone, and a
|
||||||
|
// canonical respelling of the same page is not a move.
|
||||||
|
const currentRouteKey = routeKey(route);
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status !== "ready") {
|
||||||
|
shownRouteRef.current = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shownRouteRef.current !== undefined && shownRouteRef.current !== currentRouteKey) {
|
||||||
|
mainRef.current?.focus();
|
||||||
|
}
|
||||||
|
shownRouteRef.current = currentRouteKey;
|
||||||
|
}, [currentRouteKey, state.status]);
|
||||||
|
|
||||||
const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1);
|
const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1);
|
||||||
|
|
||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
@@ -553,7 +620,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
|||||||
|
|
||||||
const { bootstrap } = state;
|
const { bootstrap } = state;
|
||||||
const entries = allEntries(bootstrap);
|
const entries = allEntries(bootstrap);
|
||||||
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
|
|
||||||
const { groups, ungrouped } = bootstrap.navigation;
|
const { groups, ungrouped } = bootstrap.navigation;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -569,22 +635,30 @@ export function HostApp(props: HostAppProps): ReactElement {
|
|||||||
</header>
|
</header>
|
||||||
<aside style={shellStyles.aside}>
|
<aside style={shellStyles.aside}>
|
||||||
<DevicePanel config={config} />
|
<DevicePanel config={config} />
|
||||||
|
<nav aria-label="Dashboard" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||||
|
<DashboardLink
|
||||||
|
current={route.kind === "overview"}
|
||||||
|
data-testid="select-overview"
|
||||||
|
history={history}
|
||||||
|
to={{ kind: "overview" }}
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</DashboardLink>
|
||||||
{entries.length === 0 && groups.length === 0 ? (
|
{entries.length === 0 && groups.length === 0 ? (
|
||||||
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
|
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
|
||||||
No installed module provides a user interface.
|
No installed module provides a user interface.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : undefined}
|
||||||
<nav aria-label="Modules" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<section data-testid={`group-${group.id}`} key={group.id}>
|
<section data-testid={`group-${group.id}`} key={group.id}>
|
||||||
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
|
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
|
||||||
<div style={{ display: "grid", gap: "0.125rem" }}>
|
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||||
{group.modules.map((entry) => (
|
{group.modules.map((entry) => (
|
||||||
<ModuleButton
|
<ModuleLink
|
||||||
entry={entry}
|
entry={entry}
|
||||||
|
history={history}
|
||||||
key={moduleKey(entry)}
|
key={moduleKey(entry)}
|
||||||
onSelect={() => setSelectedKey(moduleKey(entry))}
|
route={route}
|
||||||
selected={moduleKey(entry) === selectedKey}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -595,30 +669,37 @@ export function HostApp(props: HostAppProps): ReactElement {
|
|||||||
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
|
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
|
||||||
<div style={{ display: "grid", gap: "0.125rem" }}>
|
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||||
{ungrouped.map((entry) => (
|
{ungrouped.map((entry) => (
|
||||||
<ModuleButton
|
<ModuleLink
|
||||||
entry={entry}
|
entry={entry}
|
||||||
|
history={history}
|
||||||
key={moduleKey(entry)}
|
key={moduleKey(entry)}
|
||||||
onSelect={() => setSelectedKey(moduleKey(entry))}
|
route={route}
|
||||||
selected={moduleKey(entry) === selectedKey}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
|
||||||
</aside>
|
</aside>
|
||||||
<main style={shellStyles.main}>
|
<main aria-label={pageTitle} ref={mainRef} style={shellStyles.main} tabIndex={-1}>
|
||||||
{selected ? (
|
{route.kind === "overview" ? (
|
||||||
|
<OverviewPage />
|
||||||
|
) : route.kind === "module" ? (
|
||||||
|
openEntry ? (
|
||||||
<ModuleUiFrame
|
<ModuleUiFrame
|
||||||
config={config}
|
config={config}
|
||||||
descriptor={selected.descriptor}
|
descriptor={openEntry.descriptor}
|
||||||
{...(importModule ? { importModule } : {})}
|
{...(importModule ? { importModule } : {})}
|
||||||
key={moduleKey(selected)}
|
key={moduleKey(openEntry)}
|
||||||
onAuthorizationResponse={onAuthorizationResponse}
|
onAuthorizationResponse={onAuthorizationResponse}
|
||||||
principal={bootstrap.principal}
|
principal={bootstrap.principal}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : (
|
||||||
|
<ModuleUnavailablePage history={history} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<NotFoundPage history={history} />
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { EmptyState, PageHeader } from "@ekklesia/ui";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { DashboardLink } from "./DashboardLink.js";
|
||||||
|
import type { HostHistory } from "./host-history.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dashboard's landing page (DESIGN.md §5, ADR 0057).
|
||||||
|
*
|
||||||
|
* The Overview is where the Dashboard opens. Its cards will come from modules through a contract
|
||||||
|
* that does not exist yet, so for now it honestly says it is empty rather than inventing content.
|
||||||
|
*/
|
||||||
|
export function OverviewPage(): ReactElement {
|
||||||
|
return (
|
||||||
|
<div data-testid="host-overview">
|
||||||
|
<PageHeader title="Overview" />
|
||||||
|
<EmptyState
|
||||||
|
description="Summaries from your modules will appear here as they become available. Choose a module in the navigation to open it."
|
||||||
|
title="Nothing to show yet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A module page whose module is not available to this Dashboard: it was disabled or removed, it
|
||||||
|
* provides no user interface, or the address was mistyped. The server's navigation is the only
|
||||||
|
* authority on what exists, and it does not say which of these applies, so neither does this page.
|
||||||
|
* It never echoes the address back.
|
||||||
|
*/
|
||||||
|
export function ModuleUnavailablePage(props: { readonly history: HostHistory }): ReactElement {
|
||||||
|
return (
|
||||||
|
<div data-testid="host-module-unavailable">
|
||||||
|
<PageHeader
|
||||||
|
actions={<OverviewAction history={props.history} />}
|
||||||
|
description="The module it belongs to may have been turned off or removed. If you expected to see it, ask an administrator."
|
||||||
|
title="This page isn’t available"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any other address inside the Dashboard. */
|
||||||
|
export function NotFoundPage(props: { readonly history: HostHistory }): ReactElement {
|
||||||
|
return (
|
||||||
|
<div data-testid="host-not-found">
|
||||||
|
<PageHeader
|
||||||
|
actions={<OverviewAction history={props.history} />}
|
||||||
|
description="There is nothing at this address. Check it, or start again from the Overview."
|
||||||
|
title="Page not found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewAction(props: { readonly history: HostHistory }): ReactElement {
|
||||||
|
return (
|
||||||
|
<DashboardLink
|
||||||
|
data-testid="host-go-to-overview"
|
||||||
|
history={props.history}
|
||||||
|
to={{ kind: "overview" }}
|
||||||
|
variant="action"
|
||||||
|
>
|
||||||
|
Go to Overview
|
||||||
|
</DashboardLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* The shell's one view of the address bar (ADR 0057).
|
||||||
|
*
|
||||||
|
* The Dashboard's URL is the source of truth for which page is open: a refresh, a bookmark, and the
|
||||||
|
* browser's Back and Forward buttons all have to land on the same page that was showing. Everything
|
||||||
|
* in the shell reads the current path and moves between pages through this small interface rather
|
||||||
|
* than through `window.history`, for two reasons. It keeps the entry point the only file in this
|
||||||
|
* package that touches a browser global — the browser implementation is handed the window's pieces,
|
||||||
|
* it does not reach for them. And it lets every routing behaviour be tested against an in-memory
|
||||||
|
* history whose entries a test can inspect.
|
||||||
|
*
|
||||||
|
* Deliberately not a routing library. The shell's pages are a handful of host-owned screens plus one
|
||||||
|
* mounted module, and `react-router-dom` is refused to modules precisely because routing belongs to
|
||||||
|
* the host (ADR 0026); adopting it here would prejudge how a module's own pages are addressed, which
|
||||||
|
* a later decision owns.
|
||||||
|
*/
|
||||||
|
export interface HostHistory {
|
||||||
|
/** The current path, without query or fragment. */
|
||||||
|
pathname(): string;
|
||||||
|
/** Opens a new page, adding a history entry. Opening the page already showing adds none. */
|
||||||
|
push(path: string): void;
|
||||||
|
/** Corrects the current entry in place, adding none — for a non-canonical spelling of the same
|
||||||
|
* page. */
|
||||||
|
replace(path: string): void;
|
||||||
|
/** Notifies after every change, whether made here or by the browser's Back and Forward. */
|
||||||
|
subscribe(listener: () => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The parts of a `Window` the browser history needs, passed in by the entry point. */
|
||||||
|
export interface HostHistoryWindow {
|
||||||
|
readonly location: Pick<Location, "pathname">;
|
||||||
|
readonly history: Pick<History, "pushState" | "replaceState">;
|
||||||
|
addEventListener(type: "popstate", listener: () => void): void;
|
||||||
|
removeEventListener(type: "popstate", listener: () => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address bar itself. `pushState` and `replaceState` fire no event of their own, so this
|
||||||
|
* notifies its subscribers after each one; `popstate` covers Back and Forward.
|
||||||
|
*/
|
||||||
|
export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory {
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const notify = (): void => {
|
||||||
|
for (const listener of [...listeners]) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
pathname: () => view.location.pathname,
|
||||||
|
push(path) {
|
||||||
|
if (path === view.location.pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view.history.pushState(null, "", path);
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
replace(path) {
|
||||||
|
view.history.replaceState(null, "", path);
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
subscribe(listener) {
|
||||||
|
if (listeners.size === 0) {
|
||||||
|
view.addEventListener("popstate", notify);
|
||||||
|
}
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
if (listeners.size === 0) {
|
||||||
|
view.removeEventListener("popstate", notify);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An in-memory history with the same semantics, whose entries a caller can inspect and step
|
||||||
|
* through. Used by tests; production always uses {@link createBrowserHostHistory}. */
|
||||||
|
export interface MemoryHostHistory extends HostHistory {
|
||||||
|
readonly entries: readonly string[];
|
||||||
|
readonly index: number;
|
||||||
|
/** Steps like the browser's Back (`-1`) and Forward (`1`) buttons. */
|
||||||
|
go(delta: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory {
|
||||||
|
const entries: string[] = [initialPath];
|
||||||
|
let index = 0;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const notify = (): void => {
|
||||||
|
for (const listener of [...listeners]) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
get entries() {
|
||||||
|
return [...entries];
|
||||||
|
},
|
||||||
|
get index() {
|
||||||
|
return index;
|
||||||
|
},
|
||||||
|
pathname: () => entries[index] ?? "/",
|
||||||
|
push(path) {
|
||||||
|
if (path === entries[index]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entries.splice(index + 1, entries.length, path);
|
||||||
|
index = entries.length - 1;
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
replace(path) {
|
||||||
|
entries[index] = path;
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
go(delta) {
|
||||||
|
const next = Math.min(Math.max(index + delta, 0), entries.length - 1);
|
||||||
|
if (next !== index) {
|
||||||
|
index = next;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { consumeAuthLanding } from "../auth-protocol.js";
|
import { consumeAuthLanding } from "../auth-protocol.js";
|
||||||
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
|
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
|
||||||
import { HostApp } from "./HostApp.js";
|
import { HostApp } from "./HostApp.js";
|
||||||
|
import { createBrowserHostHistory } from "./host-history.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one place the shell touches a browser global.
|
* The one place the shell touches a browser global.
|
||||||
@@ -23,6 +24,13 @@ const apiBaseUrl = resolveApiOrigin(
|
|||||||
* `consumeAuthLanding` for why the token must not stay in the URL. */
|
* `consumeAuthLanding` for why the token must not stay in the URL. */
|
||||||
const authLanding = consumeAuthLanding(window.location, window.history);
|
const authLanding = consumeAuthLanding(window.location, window.history);
|
||||||
|
|
||||||
|
/** The address bar, handed to the shell once so no other file reads `window.location` (ADR 0057). */
|
||||||
|
const history = createBrowserHostHistory(window);
|
||||||
|
|
||||||
|
const setDocumentTitle = (title: string): void => {
|
||||||
|
document.title = title;
|
||||||
|
};
|
||||||
|
|
||||||
const container = document.getElementById("ekklesia-host");
|
const container = document.getElementById("ekklesia-host");
|
||||||
if (!container) {
|
if (!container) {
|
||||||
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
|
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
|
||||||
@@ -53,9 +61,14 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return authLanding ? (
|
return authLanding ? (
|
||||||
<HostApp authLanding={authLanding} config={config} />
|
<HostApp
|
||||||
|
authLanding={authLanding}
|
||||||
|
config={config}
|
||||||
|
history={history}
|
||||||
|
setDocumentTitle={setDocumentTitle}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<HostApp config={config} />
|
<HostApp config={config} history={history} setDocumentTitle={setDocumentTitle} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* The Dashboard's URL space (ADR 0057): what each Dashboard path means.
|
||||||
|
*
|
||||||
|
* Framework-free and free of browser globals, because two programs read it. The shell parses the
|
||||||
|
* address bar with it, and `apps/api` serves the Dashboard page at the platform pages it lists, so a
|
||||||
|
* refreshed or bookmarked deep link reaches the shell instead of a `404`.
|
||||||
|
*
|
||||||
|
* Module pages and platform pages share one top-level namespace:
|
||||||
|
*
|
||||||
|
* ```text
|
||||||
|
* / Overview
|
||||||
|
* /settings[/…] Settings
|
||||||
|
* /account[/…] the account area
|
||||||
|
* /<module-id>[/…] a module's page, and later its own sub-pages
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
|
||||||
|
* Dashboard UI, and Core refuses any module whose id is a platform-owned name (`RESERVED_MODULE_IDS`
|
||||||
|
* in `@ekklesia/core/modules`), so a module page can never shadow a platform page and an arbitrary
|
||||||
|
* root path never becomes one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dashboard's own top-level pages besides the Overview. Reserved whole, including paths no page
|
||||||
|
* uses yet, so adding a Settings or Account page needs no server or proxy change. Each is also a
|
||||||
|
* reserved module id in Core; `apps/api` tests hold the two lists together.
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const);
|
||||||
|
|
||||||
|
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
|
||||||
|
export type DashboardRoute =
|
||||||
|
| { readonly kind: "overview" }
|
||||||
|
| { readonly kind: "module"; readonly moduleId: string }
|
||||||
|
| { readonly kind: "not-found" };
|
||||||
|
|
||||||
|
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
|
||||||
|
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
|
||||||
|
|
||||||
|
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
|
||||||
|
|
||||||
|
function decodeSegment(segment: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(segment);
|
||||||
|
return decoded.length > 0 ? decoded : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a path into a route.
|
||||||
|
*
|
||||||
|
* A single top-level segment that is not a platform page names a module's page. The id is only
|
||||||
|
* decoded here, never validated against a pattern: the shell compares it with the navigation the
|
||||||
|
* server resolved, and an id that matches no entry is an unavailable module, not a malformed URL.
|
||||||
|
*
|
||||||
|
* Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), which
|
||||||
|
* need a contract that does not exist yet, so it is `not-found` for now rather than silently showing
|
||||||
|
* the module's first page. Settings and Account pages are likewise `not-found` until they exist. One
|
||||||
|
* trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the
|
||||||
|
* canonical spelling.
|
||||||
|
*/
|
||||||
|
export function parseDashboardPath(pathname: string): DashboardRoute {
|
||||||
|
if (pathname === "/") {
|
||||||
|
return { kind: "overview" };
|
||||||
|
}
|
||||||
|
const segments = pathname.split("/");
|
||||||
|
if (segments[0] !== "") {
|
||||||
|
return { kind: "not-found" };
|
||||||
|
}
|
||||||
|
const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1);
|
||||||
|
const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]);
|
||||||
|
if (first === undefined || platformPages.has(first)) {
|
||||||
|
return { kind: "not-found" };
|
||||||
|
}
|
||||||
|
if (meaningful.length === 1) {
|
||||||
|
return { kind: "module", moduleId: first };
|
||||||
|
}
|
||||||
|
return { kind: "not-found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
|
||||||
|
* own path segment. */
|
||||||
|
export function dashboardPath(route: DashboardDestination): string {
|
||||||
|
if (route.kind === "overview") {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
return `/${encodeURIComponent(route.moduleId)}`;
|
||||||
|
}
|
||||||
@@ -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 } : {}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
|
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
|
||||||
import { HostApp } from "../src/browser/HostApp.js";
|
import { HostApp } from "../src/browser/HostApp.js";
|
||||||
|
import { createMemoryHostHistory } from "../src/browser/host-history.js";
|
||||||
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,7 +95,13 @@ function platform(stub: AuthStub = {}): Platform {
|
|||||||
|
|
||||||
async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
|
async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />);
|
root.render(
|
||||||
|
<HostApp
|
||||||
|
config={config}
|
||||||
|
history={createMemoryHostHistory("/")}
|
||||||
|
{...(authLanding ? { authLanding } : {})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DASHBOARD_PLATFORM_PAGES,
|
||||||
|
dashboardPath,
|
||||||
|
parseDashboardPath,
|
||||||
|
} from "../src/dashboard-routes.js";
|
||||||
|
|
||||||
|
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||||
|
|
||||||
|
describe("the Dashboard's own top-level pages", () => {
|
||||||
|
it("are exactly Settings and Account, frozen", () => {
|
||||||
|
expect(Object.isFrozen(DASHBOARD_PLATFORM_PAGES)).toBe(true);
|
||||||
|
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseDashboardPath", () => {
|
||||||
|
it("makes the root the Overview", () => {
|
||||||
|
expect(parseDashboardPath("/")).toEqual({ kind: "overview" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a module page by its id at the top level, tolerating one trailing slash", () => {
|
||||||
|
expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
|
||||||
|
expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
|
||||||
|
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
|
||||||
|
expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never reads a platform page as a module page", () => {
|
||||||
|
for (const pathname of ["/settings", "/settings/", "/account", "/account/appearance"]) {
|
||||||
|
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats every other shape as not found rather than guessing", () => {
|
||||||
|
for (const pathname of [
|
||||||
|
"",
|
||||||
|
"//",
|
||||||
|
"//signage",
|
||||||
|
"/signage//",
|
||||||
|
// A module's own sub-pages need a contract that does not exist yet (ADR 0057).
|
||||||
|
"/signage/timelines",
|
||||||
|
"/signage/timelines/abc",
|
||||||
|
"/%E0%A4%A",
|
||||||
|
"/settings/displays/add",
|
||||||
|
"signage",
|
||||||
|
]) {
|
||||||
|
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dashboardPath", () => {
|
||||||
|
it("spells each route canonically, with no implementation prefix", () => {
|
||||||
|
expect(dashboardPath({ kind: "overview" })).toBe("/");
|
||||||
|
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/signage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes a module id so it can never escape its own segment", () => {
|
||||||
|
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb");
|
||||||
|
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
|
||||||
|
kind: "module",
|
||||||
|
moduleId: "a/../b",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through the parser", () => {
|
||||||
|
for (const route of [
|
||||||
|
{ kind: "overview" } as const,
|
||||||
|
{ kind: "module", moduleId: "example" } as const,
|
||||||
|
]) {
|
||||||
|
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the route locator is published for the API that serves the page", () => {
|
||||||
|
it("is a narrow package subpath, not the shell root barrel", async () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||||
|
) as { exports?: Record<string, unknown> };
|
||||||
|
expect(manifest.exports?.["./dashboard-routes"]).toEqual({
|
||||||
|
import: "./dist/dashboard-routes.js",
|
||||||
|
types: "./dist/dashboard-routes.d.ts",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { act, Component, type ReactElement, type ReactNode } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { HostApp } from "../src/browser/HostApp.js";
|
import { HostApp } from "../src/browser/HostApp.js";
|
||||||
|
import { createMemoryHostHistory } from "../src/browser/host-history.js";
|
||||||
import { DisplayApp } from "../src/display/DisplayApp.js";
|
import { DisplayApp } from "../src/display/DisplayApp.js";
|
||||||
import type {
|
import type {
|
||||||
EkklesiaHostConfig,
|
EkklesiaHostConfig,
|
||||||
@@ -180,6 +181,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 +194,7 @@ async function render(
|
|||||||
config={config}
|
config={config}
|
||||||
{...(connectivity ? { connectivity } : {})}
|
{...(connectivity ? { connectivity } : {})}
|
||||||
{...(importModule ? { importModule } : {})}
|
{...(importModule ? { importModule } : {})}
|
||||||
|
{...timing}
|
||||||
moduleId={moduleId}
|
moduleId={moduleId}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -386,7 +394,15 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
await act(async () => root.render(<HostApp config={config} importModule={importer} />));
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<HostApp
|
||||||
|
config={config}
|
||||||
|
history={createMemoryHostHistory("/announcements")}
|
||||||
|
importModule={importer}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
// The management artifact rendered inside its own isolated root, not the shell's own tree.
|
// The management artifact rendered inside its own isolated root, not the shell's own tree.
|
||||||
expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain(
|
expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain(
|
||||||
"announcement management",
|
"announcement management",
|
||||||
@@ -926,3 +942,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { act } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { HostApp } from "../src/browser/HostApp.js";
|
import { HostApp } from "../src/browser/HostApp.js";
|
||||||
|
import { createBrowserHostHistory } from "../src/browser/host-history.js";
|
||||||
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||||
import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
|
import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
|
||||||
|
|
||||||
@@ -122,12 +123,27 @@ function artifact(id: string, version: string, options: ArtifactOptions = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the shell over the real (happy-dom) address bar. `path` opens that page first, exactly as
|
||||||
|
* a deep link or a refresh would (ADR 0057); left out, the shell opens wherever the location already
|
||||||
|
* is — `/`, the Overview, unless a test put something else there.
|
||||||
|
*/
|
||||||
async function render(
|
async function render(
|
||||||
config: EkklesiaHostConfig,
|
config: EkklesiaHostConfig,
|
||||||
importModule?: (url: string) => Promise<unknown>,
|
importModule?: (url: string) => Promise<unknown>,
|
||||||
|
path?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (path !== undefined) {
|
||||||
|
window.history.replaceState(null, "", path);
|
||||||
|
}
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(<HostApp config={config} {...(importModule ? { importModule } : {})} />);
|
root.render(
|
||||||
|
<HostApp
|
||||||
|
config={config}
|
||||||
|
history={createBrowserHostHistory(window)}
|
||||||
|
{...(importModule ? { importModule } : {})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +284,11 @@ describe("Ekklesia shell — bootstrap", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Ekklesia shell — browser authentication", () => {
|
describe("Ekklesia shell — browser authentication", () => {
|
||||||
|
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
|
||||||
|
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
|
||||||
|
beforeEach(() => {
|
||||||
|
window.history.replaceState(null, "", "/example");
|
||||||
|
});
|
||||||
it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
|
it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
|
||||||
let releasePrincipal: (() => void) | undefined;
|
let releasePrincipal: (() => void) | undefined;
|
||||||
const principalPending = new Promise<void>((resolve) => {
|
const principalPending = new Promise<void>((resolve) => {
|
||||||
@@ -539,6 +560,11 @@ describe("Ekklesia shell — browser authentication", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Ekklesia shell — logout and session expiration", () => {
|
describe("Ekklesia shell — logout and session expiration", () => {
|
||||||
|
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
|
||||||
|
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
|
||||||
|
beforeEach(() => {
|
||||||
|
window.history.replaceState(null, "", "/example");
|
||||||
|
});
|
||||||
it("unmounts module UI and closes realtime before completing server-side logout", async () => {
|
it("unmounts module UI and closes realtime before completing server-side logout", async () => {
|
||||||
let releaseSignOut: (() => void) | undefined;
|
let releaseSignOut: (() => void) | undefined;
|
||||||
const signOutPending = new Promise<void>((resolve) => {
|
const signOutPending = new Promise<void>((resolve) => {
|
||||||
@@ -856,10 +882,8 @@ describe("Ekklesia shell — Dashboard navigation", () => {
|
|||||||
|
|
||||||
const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent);
|
const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent);
|
||||||
expect(labels).toEqual(["Planning", "Media"]);
|
expect(labels).toEqual(["Planning", "Media"]);
|
||||||
const buttons = [...container.querySelectorAll("nav button")].map(
|
const links = [...container.querySelectorAll("nav section a")].map((link) => link.textContent);
|
||||||
(button) => button.textContent,
|
expect(links).toEqual(["Calendar", "Livestream", "Video"]);
|
||||||
);
|
|
||||||
expect(buttons).toEqual(["Calendar", "Livestream", "Video"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders ungrouped modules alongside groups, under a heading", async () => {
|
it("renders ungrouped modules alongside groups, under a heading", async () => {
|
||||||
@@ -897,14 +921,29 @@ describe("Ekklesia shell — Dashboard navigation", () => {
|
|||||||
expect(query("group-media")?.textContent).toBe("Media");
|
expect(query("group-media")?.textContent).toBe("Media");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects the first module in the arranged order and marks it active", async () => {
|
it("opens on the Overview rather than choosing a module, whatever the arrangement", async () => {
|
||||||
|
const importModule = vi.fn(async () => artifact("video", "0.1.0"));
|
||||||
await render(
|
await render(
|
||||||
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
|
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
|
||||||
async (url) => artifact(url.includes("/video/") ? "video" : "other", "0.1.0"),
|
importModule,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(query("select-video")?.getAttribute("aria-current")).toBe("page");
|
expect(query("select-overview")?.getAttribute("aria-current")).toBe("page");
|
||||||
expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull();
|
expect(query("select-video")?.getAttribute("aria-current")).toBeNull();
|
||||||
|
expect(query("module-container")).toBeNull();
|
||||||
|
expect(importModule).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the open module's link as the current page", async () => {
|
||||||
|
await render(
|
||||||
|
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
|
||||||
|
async () => artifact("livestream", "2.0.0"),
|
||||||
|
"/livestream",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(query("select-livestream")?.getAttribute("aria-current")).toBe("page");
|
||||||
|
expect(query("select-video")?.getAttribute("aria-current")).toBeNull();
|
||||||
|
expect(query("select-overview")?.getAttribute("aria-current")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mounts a module selected from inside a group, using the descriptor the group carried", async () => {
|
it("mounts a module selected from inside a group, using the descriptor the group carried", async () => {
|
||||||
@@ -915,6 +954,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
|
|||||||
requested.push(url);
|
requested.push(url);
|
||||||
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
|
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
|
||||||
},
|
},
|
||||||
|
"/video",
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -972,6 +1012,11 @@ describe("Ekklesia shell — Dashboard navigation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Ekklesia shell — mount lifecycle", () => {
|
describe("Ekklesia shell — mount lifecycle", () => {
|
||||||
|
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
|
||||||
|
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
|
||||||
|
beforeEach(() => {
|
||||||
|
window.history.replaceState(null, "", "/example");
|
||||||
|
});
|
||||||
it("keeps fixed shell tracks while module content changes size", async () => {
|
it("keeps fixed shell tracks while module content changes size", async () => {
|
||||||
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0"));
|
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0"));
|
||||||
|
|
||||||
@@ -1140,6 +1185,11 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Ekklesia shell — failure is host-owned", () => {
|
describe("Ekklesia shell — failure is host-owned", () => {
|
||||||
|
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
|
||||||
|
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
|
||||||
|
beforeEach(() => {
|
||||||
|
window.history.replaceState(null, "", "/example");
|
||||||
|
});
|
||||||
it("renders its own error when the artifact cannot be imported", async () => {
|
it("renders its own error when the artifact cannot be imported", async () => {
|
||||||
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
|
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
|
||||||
throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing");
|
throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing");
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createBrowserHostHistory, createMemoryHostHistory } from "../src/browser/host-history.js";
|
||||||
|
|
||||||
|
describe("the browser host history", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the address bar's path and moves it with real history entries", () => {
|
||||||
|
const history = createBrowserHostHistory(window);
|
||||||
|
const lengthBefore = window.history.length;
|
||||||
|
|
||||||
|
history.push("/example");
|
||||||
|
|
||||||
|
expect(history.pathname()).toBe("/example");
|
||||||
|
expect(window.location.pathname).toBe("/example");
|
||||||
|
expect(window.history.length).toBe(lengthBefore + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds no entry when the page asked for is already open", () => {
|
||||||
|
const history = createBrowserHostHistory(window);
|
||||||
|
history.push("/example");
|
||||||
|
const length = window.history.length;
|
||||||
|
const listener = vi.fn();
|
||||||
|
history.subscribe(listener);
|
||||||
|
|
||||||
|
history.push("/example");
|
||||||
|
|
||||||
|
expect(window.history.length).toBe(length);
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("corrects the current entry in place with replace", () => {
|
||||||
|
const history = createBrowserHostHistory(window);
|
||||||
|
history.push("/example/");
|
||||||
|
const length = window.history.length;
|
||||||
|
|
||||||
|
history.replace("/example");
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe("/example");
|
||||||
|
expect(window.history.length).toBe(length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies after its own changes and after the browser's Back and Forward", () => {
|
||||||
|
const history = createBrowserHostHistory(window);
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = history.subscribe(listener);
|
||||||
|
|
||||||
|
history.push("/a");
|
||||||
|
history.replace("/b");
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||||
|
expect(listener).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||||
|
history.push("/c");
|
||||||
|
expect(listener).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listens to popstate only while something is subscribed", () => {
|
||||||
|
const view = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
history: { pushState: vi.fn(), replaceState: vi.fn() },
|
||||||
|
location: { pathname: "/" },
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
};
|
||||||
|
const history = createBrowserHostHistory(view);
|
||||||
|
|
||||||
|
const first = history.subscribe(() => undefined);
|
||||||
|
const second = history.subscribe(() => undefined);
|
||||||
|
expect(view.addEventListener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
first();
|
||||||
|
expect(view.removeEventListener).not.toHaveBeenCalled();
|
||||||
|
second();
|
||||||
|
expect(view.removeEventListener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(view.removeEventListener.mock.calls[0]?.[1]).toBe(
|
||||||
|
view.addEventListener.mock.calls[0]?.[1],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores no state object, so nothing a page put in history can be read back as authority", () => {
|
||||||
|
const view = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
history: { pushState: vi.fn(), replaceState: vi.fn() },
|
||||||
|
location: { pathname: "/" },
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
};
|
||||||
|
const history = createBrowserHostHistory(view);
|
||||||
|
|
||||||
|
history.push("/a");
|
||||||
|
history.replace("/b");
|
||||||
|
|
||||||
|
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/a");
|
||||||
|
expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/b");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the in-memory host history", () => {
|
||||||
|
it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => {
|
||||||
|
const history = createMemoryHostHistory("/");
|
||||||
|
history.push("/a");
|
||||||
|
history.push("/b");
|
||||||
|
history.go(-1);
|
||||||
|
expect(history.pathname()).toBe("/a");
|
||||||
|
|
||||||
|
history.push("/c");
|
||||||
|
expect(history.entries).toEqual(["/", "/a", "/c"]);
|
||||||
|
|
||||||
|
history.go(-10);
|
||||||
|
expect(history.pathname()).toBe("/");
|
||||||
|
history.go(10);
|
||||||
|
expect(history.pathname()).toBe("/c");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { HostApp } from "../src/browser/HostApp.js";
|
||||||
|
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
|
||||||
|
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||||
|
import { isolatedText } from "./support/isolated-dom.js";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dashboard's URL is the source of truth for which page is open (ADR 0057). Every test drives
|
||||||
|
* the real shell through an in-memory history, so it can assert on the exact entries a person's
|
||||||
|
* Back and Forward buttons would walk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const apiBaseUrl = "https://ekklesia.example.test";
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let consoleError: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
function entry(id: string, name: string, version = "0.1.0") {
|
||||||
|
return {
|
||||||
|
module: {
|
||||||
|
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||||
|
contributions: [],
|
||||||
|
display: null,
|
||||||
|
id,
|
||||||
|
permissions: [],
|
||||||
|
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
|
||||||
|
ui: { url: `/api/v1/module-ui/${id}/${version}` },
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAVIGATION = {
|
||||||
|
groups: [{ id: "media", label: "Media", modules: [entry("signage", "Signage")], order: 10 }],
|
||||||
|
ungrouped: [entry("calendar", "Calendar", "2.0.0")],
|
||||||
|
};
|
||||||
|
|
||||||
|
function platform(options: { authenticated?: boolean } = {}) {
|
||||||
|
let authenticated = options.authenticated ?? true;
|
||||||
|
const fetchImplementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/api/auth/sign-in/email")) {
|
||||||
|
void init;
|
||||||
|
authenticated = true;
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/principal")) {
|
||||||
|
return Response.json(
|
||||||
|
authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/dashboard/navigation")) {
|
||||||
|
return authenticated
|
||||||
|
? Response.json(NAVIGATION)
|
||||||
|
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
||||||
|
}
|
||||||
|
return Response.json({ code: "NOT_FOUND" }, { status: 404 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
return fetchImplementation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactFor(url: string) {
|
||||||
|
const id = url.includes("/calendar/") ? "calendar" : "signage";
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
abi: MODULE_UI_ABI_VERSION,
|
||||||
|
id,
|
||||||
|
mount(element: Element, context: ModuleUiHostContext) {
|
||||||
|
element.textContent = `${context.moduleId} mounted`;
|
||||||
|
return () => {
|
||||||
|
element.textContent = "";
|
||||||
|
};
|
||||||
|
},
|
||||||
|
version: id === "calendar" ? "2.0.0" : "0.1.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Rendered {
|
||||||
|
readonly history: MemoryHostHistory;
|
||||||
|
readonly imported: string[];
|
||||||
|
readonly titles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(
|
||||||
|
path: string,
|
||||||
|
options: { authenticated?: boolean; history?: MemoryHostHistory } = {},
|
||||||
|
): Promise<Rendered> {
|
||||||
|
const history = options.history ?? createMemoryHostHistory(path);
|
||||||
|
const imported: string[] = [];
|
||||||
|
const titles: string[] = [];
|
||||||
|
const config: EkklesiaHostConfig = { apiBaseUrl, fetch: platform(options) };
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<HostApp
|
||||||
|
config={config}
|
||||||
|
history={history}
|
||||||
|
importModule={async (url) => {
|
||||||
|
imported.push(url);
|
||||||
|
return artifactFor(url);
|
||||||
|
}}
|
||||||
|
setDocumentTitle={(title) => titles.push(title)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { history, imported, titles };
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(testId: string): HTMLElement | null {
|
||||||
|
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function click(element: Element | null, init: MouseEventInit = {}): Promise<MouseEvent> {
|
||||||
|
if (!element) {
|
||||||
|
throw new Error("missing element");
|
||||||
|
}
|
||||||
|
const event = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init });
|
||||||
|
await act(async () => {
|
||||||
|
element.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function go(history: MemoryHostHistory, delta: number): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
history.go(delta);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the Dashboard opens on the page its URL names", () => {
|
||||||
|
it("lands on the Overview at the root, and mounts no module", async () => {
|
||||||
|
const { imported, titles } = await render("/");
|
||||||
|
|
||||||
|
expect(query("host-overview")).not.toBeNull();
|
||||||
|
expect(query("host-overview")?.querySelector("h1")?.textContent).toBe("Overview");
|
||||||
|
expect(query("module-container")).toBeNull();
|
||||||
|
expect(imported).toEqual([]);
|
||||||
|
expect(query("select-overview")?.getAttribute("aria-current")).toBe("page");
|
||||||
|
expect(titles.at(-1)).toBe("Overview");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a module's page directly from its deep link — what a refresh or a bookmark does", async () => {
|
||||||
|
const { imported, titles } = await render("/calendar");
|
||||||
|
|
||||||
|
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
|
||||||
|
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
|
||||||
|
expect(query("select-calendar")?.getAttribute("aria-current")).toBe("page");
|
||||||
|
expect(query("select-overview")?.getAttribute("aria-current")).toBeNull();
|
||||||
|
expect(query("host-overview")).toBeNull();
|
||||||
|
expect(titles.at(-1)).toBe("Calendar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a module from inside a group exactly as one outside it", async () => {
|
||||||
|
const { imported } = await render("/signage");
|
||||||
|
|
||||||
|
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`]);
|
||||||
|
expect(query("select-signage")?.getAttribute("aria-current")).toBe("page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a module page is unavailable when no navigable module has that id, and loads nothing", async () => {
|
||||||
|
const { imported, titles } = await render("/disabled-module");
|
||||||
|
|
||||||
|
expect(query("host-module-unavailable")).not.toBeNull();
|
||||||
|
expect(query("module-container")).toBeNull();
|
||||||
|
expect(imported).toEqual([]);
|
||||||
|
// The address is never echoed back into the page.
|
||||||
|
expect(container.textContent).not.toContain("disabled-module");
|
||||||
|
expect(titles.at(-1)).toBe("This page isn’t available");
|
||||||
|
expect(query("host-go-to-overview")?.getAttribute("href")).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => {
|
||||||
|
for (const path of ["/settings/displays", "/calendar/events", "/account"]) {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
root = createRoot(container);
|
||||||
|
const { imported, titles } = await render(path);
|
||||||
|
|
||||||
|
expect(query("host-not-found"), path).not.toBeNull();
|
||||||
|
expect(imported, path).toEqual([]);
|
||||||
|
expect(titles.at(-1), path).toBe("Page not found");
|
||||||
|
// Nothing in the navigation claims to be the open page.
|
||||||
|
expect(container.querySelector('[aria-current="page"]'), path).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addresses module pages at the top level, beside the platform's own pages, and never confuses the two", async () => {
|
||||||
|
// Core refuses a module whose id is a platform page, but even a navigation that somehow listed
|
||||||
|
// one could not turn `/settings` into a module page.
|
||||||
|
const { imported } = await render("/settings");
|
||||||
|
|
||||||
|
expect(query("host-not-found")).not.toBeNull();
|
||||||
|
expect(imported).toEqual([]);
|
||||||
|
expect(query("select-signage")?.getAttribute("href")).toBe("/signage");
|
||||||
|
expect(container.innerHTML).not.toContain("/modules/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("corrects a trailing slash to the canonical address in place, adding no history entry", async () => {
|
||||||
|
const { history, imported } = await render("/calendar/");
|
||||||
|
|
||||||
|
expect(history.entries).toEqual(["/calendar"]);
|
||||||
|
expect(imported).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the deep link it was given once the person signs in, with no redirect", async () => {
|
||||||
|
const history = createMemoryHostHistory("/calendar");
|
||||||
|
const { imported } = await render("/calendar", { authenticated: false, history });
|
||||||
|
expect(query("host-login")).not.toBeNull();
|
||||||
|
|
||||||
|
const email = query("login-email") as HTMLInputElement;
|
||||||
|
const password = query("login-password") as HTMLInputElement;
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||||
|
await act(async () => {
|
||||||
|
setter?.call(email, "[email protected]");
|
||||||
|
email.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
setter?.call(password, "correct password");
|
||||||
|
password.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
query("host-login")
|
||||||
|
?.querySelector("form")
|
||||||
|
?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
|
||||||
|
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
|
||||||
|
expect(history.entries).toEqual(["/calendar"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moving between pages", () => {
|
||||||
|
it("renders navigation as real links to canonical addresses", async () => {
|
||||||
|
await render("/");
|
||||||
|
|
||||||
|
for (const [testId, href] of [
|
||||||
|
["select-overview", "/"],
|
||||||
|
["select-signage", "/signage"],
|
||||||
|
["select-calendar", "/calendar"],
|
||||||
|
] as const) {
|
||||||
|
const link = query(testId);
|
||||||
|
expect(link?.tagName, testId).toBe("A");
|
||||||
|
expect(link?.getAttribute("href"), testId).toBe(href);
|
||||||
|
}
|
||||||
|
expect(container.querySelector('nav[aria-label="Dashboard"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a page in place on an ordinary click, adding one history entry", async () => {
|
||||||
|
const { history, imported } = await render("/");
|
||||||
|
|
||||||
|
const event = await click(query("select-calendar"));
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
expect(history.entries).toEqual(["/", "/calendar"]);
|
||||||
|
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
|
||||||
|
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a modified or middle click to the browser, so a page can open in a new tab", async () => {
|
||||||
|
const { history } = await render("/");
|
||||||
|
|
||||||
|
for (const init of [
|
||||||
|
{ ctrlKey: true },
|
||||||
|
{ metaKey: true },
|
||||||
|
{ shiftKey: true },
|
||||||
|
{ altKey: true },
|
||||||
|
{ button: 1 },
|
||||||
|
]) {
|
||||||
|
const event = await click(query("select-calendar"), init);
|
||||||
|
expect(event.defaultPrevented, JSON.stringify(init)).toBe(false);
|
||||||
|
}
|
||||||
|
expect(history.entries).toEqual(["/"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds no entry when the open page's own link is clicked again", async () => {
|
||||||
|
const { history } = await render("/calendar");
|
||||||
|
|
||||||
|
await click(query("select-calendar"));
|
||||||
|
|
||||||
|
expect(history.entries).toEqual(["/calendar"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks Back and Forward through exactly the pages that were open", async () => {
|
||||||
|
const { history, imported } = await render("/");
|
||||||
|
await click(query("select-signage"));
|
||||||
|
await click(query("select-calendar"));
|
||||||
|
|
||||||
|
await go(history, -1);
|
||||||
|
expect(isolatedText(query("module-container"))).toBe("signage mounted");
|
||||||
|
expect(query("select-signage")?.getAttribute("aria-current")).toBe("page");
|
||||||
|
|
||||||
|
await go(history, -1);
|
||||||
|
expect(query("host-overview")).not.toBeNull();
|
||||||
|
expect(query("module-container")).toBeNull();
|
||||||
|
|
||||||
|
await go(history, 1);
|
||||||
|
expect(isolatedText(query("module-container"))).toBe("signage mounted");
|
||||||
|
expect(imported).toEqual([
|
||||||
|
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
|
||||||
|
`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`,
|
||||||
|
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
|
||||||
|
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the Overview from an unavailable page through its own link", async () => {
|
||||||
|
const { history } = await render("/missing");
|
||||||
|
|
||||||
|
await click(query("host-go-to-overview"));
|
||||||
|
|
||||||
|
expect(history.entries).toEqual(["/missing", "/"]);
|
||||||
|
expect(query("host-overview")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves focus to the new page's content after a page change, but not on first load", async () => {
|
||||||
|
await render("/");
|
||||||
|
const main = container.querySelector("main");
|
||||||
|
expect(main?.getAttribute("tabindex")).toBe("-1");
|
||||||
|
expect(document.activeElement).not.toBe(main);
|
||||||
|
|
||||||
|
await click(query("select-calendar"));
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(main);
|
||||||
|
expect(main?.getAttribute("aria-label")).toBe("Calendar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the browser tab after each page as it opens", async () => {
|
||||||
|
const { titles } = await render("/");
|
||||||
|
await click(query("select-signage"));
|
||||||
|
await click(query("select-overview"));
|
||||||
|
|
||||||
|
expect(titles).toEqual(["Overview", "Signage", "Overview"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"src/index.ts",
|
"src/index.ts",
|
||||||
"src/dashboard-assets.ts",
|
"src/dashboard-assets.ts",
|
||||||
|
"src/dashboard-routes.ts",
|
||||||
"src/display-assets.ts",
|
"src/display-assets.ts",
|
||||||
"src/host-protocol.ts",
|
"src/host-protocol.ts",
|
||||||
"src/module-context.ts"
|
"src/module-context.ts"
|
||||||
|
|||||||
@@ -128,6 +128,34 @@ describe("CI scripts on a shared Docker daemon", () => {
|
|||||||
expect(workflow.jobs["deploy-smoke"]?.env?.EKKLESIA_CI_JOB_KEY).toContain("matrix.topology");
|
expect(workflow.jobs["deploy-smoke"]?.env?.EKKLESIA_CI_JOB_KEY).toContain("matrix.topology");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never sweep another run's live stack early, not even from the sweep's own self-test", async () => {
|
||||||
|
// The workflow's stale sweeps keep the default threshold, which is far above any job's run time.
|
||||||
|
const workflow = await source(".github/workflows/ci.yml");
|
||||||
|
const workflowSweeps = workflow
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.includes("ci-sweep.sh --stale"));
|
||||||
|
expect(workflowSweeps.length).toBeGreaterThan(0);
|
||||||
|
for (const line of workflowSweeps) {
|
||||||
|
expect(line).not.toContain("--max-age-seconds");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The self-test does use short thresholds, so every stale sweep it runs is confined to its own
|
||||||
|
// fake run. Unconfined, `--max-age-seconds 0` removed every CI stack on the shared daemon —
|
||||||
|
// including a module repository's integration stack mid-run.
|
||||||
|
const selfTest = await source("scripts/test-ci-sweep.sh");
|
||||||
|
expect(selfTest).toMatch(/^own_run="\$\(ci_name_safe "ekklesia-ci-\$\{GITHUB_RUN_ID\}-"\)"$/mu);
|
||||||
|
expect(selfTest.indexOf('export GITHUB_RUN_ID="sweeptest')).toBeLessThan(
|
||||||
|
selfTest.indexOf("own_run="),
|
||||||
|
);
|
||||||
|
const selfTestSweeps = selfTest
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => /^\s*bash scripts\/ci-sweep\.sh --stale/u.test(line));
|
||||||
|
expect(selfTestSweeps.length).toBeGreaterThan(0);
|
||||||
|
for (const line of selfTestSweeps) {
|
||||||
|
expect(line).toContain('--prefix "$own_run"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
|
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
|
||||||
const deploy = await source("scripts/smoke-deploy.sh");
|
const deploy = await source("scripts/smoke-deploy.sh");
|
||||||
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
|
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
|
||||||
@@ -179,7 +207,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 +215,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 +239,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, " ");
|
||||||
|
|||||||
+65
-1
@@ -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"))
|
||||||
@@ -189,6 +189,12 @@ describe("reference deployment compose files", () => {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
|
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
|
||||||
|
// The Dashboard's other pages (ADR 0057): every top-level name shaped like a module id, and
|
||||||
|
// nothing else, so the API — which knows the installed modules — decides page or 404.
|
||||||
|
expect(directives).toContain(
|
||||||
|
`@dashboardPage path_regexp dashboardPage ${DASHBOARD_PAGE_PATTERN}`,
|
||||||
|
);
|
||||||
|
expect(directives).toMatch(/handle @dashboardPage \{\s*reverse_proxy api:3000 \{/u);
|
||||||
expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
|
expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
|
||||||
expect(directives).toContain("header_up -CF-Connecting-IP");
|
expect(directives).toContain("header_up -CF-Connecting-IP");
|
||||||
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
|
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
|
||||||
@@ -200,6 +206,64 @@ describe("reference deployment compose files", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The proxies' Dashboard-page matcher: a module id's grammar (`@ekklesia/contracts`) as the first
|
||||||
|
* segment, then anything. Checked against real paths below so the regular expression itself is
|
||||||
|
* tested, not just its spelling. */
|
||||||
|
const DASHBOARD_PAGE_PATTERN = "^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$";
|
||||||
|
|
||||||
|
describe("the proxies' Dashboard-page matcher", () => {
|
||||||
|
const matcher = new RegExp(DASHBOARD_PAGE_PATTERN, "u");
|
||||||
|
|
||||||
|
it("forwards every Dashboard page and module-shaped name", () => {
|
||||||
|
for (const path of [
|
||||||
|
"/settings",
|
||||||
|
"/settings/displays/add",
|
||||||
|
"/account",
|
||||||
|
"/signage",
|
||||||
|
"/signage/",
|
||||||
|
"/signage/timelines",
|
||||||
|
"/sunday-production",
|
||||||
|
"/definitely-not-a-real-module",
|
||||||
|
]) {
|
||||||
|
expect(matcher.test(path), path).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards nothing that could not be a module id", () => {
|
||||||
|
for (const path of [
|
||||||
|
"/",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/robots.txt",
|
||||||
|
"/.well-known/x",
|
||||||
|
"/Signage",
|
||||||
|
"/-x",
|
||||||
|
"/1x",
|
||||||
|
"//signage",
|
||||||
|
"/a--b",
|
||||||
|
]) {
|
||||||
|
expect(matcher.test(path), path).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("split-origin reference proxy", () => {
|
||||||
|
it("serves the Dashboard's page, its deep links, and its script on the Dashboard hostname, and no API", async () => {
|
||||||
|
const caddyfile = await text("Caddyfile.split.example");
|
||||||
|
const directives = caddyfile
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !line.trim().startsWith("#"))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
expect(directives).toContain("@dashboard path / /assets/ekklesia-dashboard/*");
|
||||||
|
expect(directives).toContain(`path_regexp dashboardPage ${DASHBOARD_PAGE_PATTERN}`);
|
||||||
|
// The API's own namespaces stay refused on the Dashboard hostname even though they are shaped
|
||||||
|
// like a module id.
|
||||||
|
expect(directives).toContain("not path /api /api/* /display /display/* /assets /assets/*");
|
||||||
|
// The API hostname still serves no Dashboard page at all.
|
||||||
|
expect(directives).toContain("@api path /api/* /display/* /assets/ekklesia-display/*");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("reference deployment env examples", () => {
|
describe("reference deployment env examples", () => {
|
||||||
it("contain no secret values", async () => {
|
it("contain no secret values", async () => {
|
||||||
const secretKeys = new Map([
|
const secretKeys = new Map([
|
||||||
|
|||||||
+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.5",
|
||||||
"@ekklesia/module-database": "^0.2.3",
|
"@ekklesia/module-database": "^0.2.5",
|
||||||
"@ekklesia/module-sdk": "^0.2.3",
|
"@ekklesia/module-sdk": "^0.2.5",
|
||||||
"@ekklesia/module-ui-sdk": "^0.2.3",
|
"@ekklesia/module-ui-sdk": "^0.2.5",
|
||||||
"@ekklesia/ui": "^0.2.3",
|
"@ekklesia/ui": "^0.2.5",
|
||||||
"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.5",
|
||||||
"@ekklesia/module-ui-build": "^0.2.3",
|
"@ekklesia/module-ui-build": "^0.2.5",
|
||||||
"@ekklesia/typescript-config": "^0.2.3",
|
"@ekklesia/typescript-config": "^0.2.5",
|
||||||
"@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