Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a7d44781b | ||
|
|
651efa8829 | ||
|
|
cf2f847c8c | ||
|
|
af31724cb4 | ||
|
|
f3b7e19651 | ||
|
|
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 | ||
|
|
9e01679fcb | ||
|
|
227c92e35d | ||
|
|
505c9e8c0a | ||
|
|
4be046c8d0 | ||
|
|
c5a6cdaf6a | ||
|
|
c4fded6ecf | ||
|
|
9ad2d5b564 | ||
|
|
98d93cc12d | ||
|
|
e083c44e05 |
@@ -27,7 +27,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
# The only registry-specific setting: where the train is published.
|
||||
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
|
||||
|
||||
jobs:
|
||||
package-release:
|
||||
|
||||
@@ -6,27 +6,60 @@ contents, because restated details drift.
|
||||
|
||||
## What this repository is
|
||||
|
||||
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
|
||||
monorepo:
|
||||
Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
|
||||
API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built
|
||||
as a strictly layered pnpm/TypeScript monorepo:
|
||||
|
||||
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
||||
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
|
||||
feature module, a surface, or an app.
|
||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
||||
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
||||
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
||||
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
||||
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
||||
execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
|
||||
imports a feature module, a surface, or an app.
|
||||
- **Modules** are independently versioned product packages. First-party modules live in **their own
|
||||
repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
|
||||
(ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
|
||||
outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
|
||||
example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
|
||||
its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
|
||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a
|
||||
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
|
||||
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
|
||||
Module Database ABI v1 (ADR 0050).
|
||||
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||
presentation concern a module does not. They consume public API/client contracts only, never Core
|
||||
or module internals directly.
|
||||
presentation concern a module does not. The shell **hosts** module UI through the generic loader;
|
||||
it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only.
|
||||
|
||||
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
||||
documented. Read `docs/architecture/repository-boundaries.md` and
|
||||
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
||||
|
||||
## Product direction lives beside this repository
|
||||
|
||||
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
|
||||
parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard
|
||||
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into
|
||||
this repository to make them available. They are authoritative for product direction: read the
|
||||
relevant stages and sections before designing a change. If this repository's documentation disagrees
|
||||
with them, flag the conflict, correct the repository-local documentation where appropriate, and never
|
||||
silently follow the stale text. Architectural decisions still change only through ADRs (below).
|
||||
|
||||
Rules from those documents that every change here must respect:
|
||||
|
||||
- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and
|
||||
devices, accounts and identity, roles and authorization, branding, mail, push, storage providers,
|
||||
and module installation/lifecycle — belong to the platform, and modules consume them.
|
||||
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from
|
||||
installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may.
|
||||
Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy.
|
||||
- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light,
|
||||
dark, and system modes. Normal UI colours come only from the shared design tokens
|
||||
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
|
||||
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
|
||||
in product UI. Destructive actions use in-product confirmation proportional to their impact.
|
||||
- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard
|
||||
sidebar's *Display devices* panel) and Signage's own display roster are a transitional
|
||||
implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not
|
||||
build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell
|
||||
redesign early, and keep existing paired displays working across any change.
|
||||
|
||||
## ADRs are authoritative
|
||||
|
||||
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
||||
@@ -43,6 +76,7 @@ At minimum, read the current versions of:
|
||||
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
||||
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
||||
- `README.md` (current platform status — what is and is not implemented)
|
||||
- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above)
|
||||
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
||||
- `CONTRIBUTING.md`
|
||||
|
||||
@@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
|
||||
|
||||
## Working conventions
|
||||
|
||||
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
|
||||
change branch-protection settings.
|
||||
- The canonical repositories are on the church Gitea under the `Zayin` organization:
|
||||
`ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and
|
||||
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
|
||||
and branch from `origin/main`, never from whatever is checked out.
|
||||
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
|
||||
merge your own PR, and never change branch-protection settings.
|
||||
- Never create or push a release tag. Release tags belong to the human maintainer.
|
||||
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
|
||||
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
|
||||
platform logic into a module to avoid making the correct Core change.
|
||||
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
||||
burying them in an implementation.
|
||||
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
||||
@@ -66,6 +108,13 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
|
||||
- Never run a database migration automatically (on import, on startup, in a test that isn't
|
||||
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
|
||||
an explicit, operator-invoked action.
|
||||
- Report what you find. A defect, flaky test, documentation/ADR conflict, or CI problem outside the
|
||||
task at hand gets an issue in the repository it belongs to, as soon as you find it. Search for an
|
||||
existing issue first; something fixed in the same PR goes in that PR's description instead. Be
|
||||
descriptive: what you saw, the evidence (run and job links, commit, file and line), how to
|
||||
reproduce it, the impact, and options, and never a secret. Label it `found-by-claude` (an agent
|
||||
that cannot apply labels ends the body with a line saying so), leave it unassigned, and list what
|
||||
you filed when you report back.
|
||||
|
||||
## Canonical verification commands
|
||||
|
||||
|
||||
@@ -59,7 +59,10 @@ supplied `LICENSE` bytes; dependency material remains separate in `THIRD_PARTY_N
|
||||
packages may bundle a module-owned Drizzle version without coupling it to Core;
|
||||
legacy-v8 packages retain their transitional exact-version check. Where a redistributed dependency
|
||||
omits licence material, the packager may add reviewed licence supplements
|
||||
([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). Purge, other
|
||||
([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). A native package that
|
||||
redistributes LGPL or MPL code is distributed publicly only with its corresponding-source artifact,
|
||||
which `ekklesia-corresponding-source` builds and gates
|
||||
([ADR 0055](docs/architecture/decisions/0055-corresponding-source-artifacts.md)). Purge, other
|
||||
operating-system or CPU targets, signing, and any installer or marketplace do not exist yet.
|
||||
|
||||
A module can be built in its own repository from Ekklesia's public module-development packages
|
||||
@@ -819,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
|
||||
public Website remain separate future designs.
|
||||
|
||||
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
||||
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
||||
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing
|
||||
(since added; see the Stage 1 paragraph below),
|
||||
and no module-management, settings, or storage UI — module enablement and configuration are an API and
|
||||
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
|
||||
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
|
||||
@@ -902,6 +906,37 @@ the module's responsibility and do not exist yet.**
|
||||
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
||||
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
||||
|
||||
Stage 1 begins with **real Dashboard URLs**
|
||||
([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The
|
||||
Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own
|
||||
address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark,
|
||||
and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages.
|
||||
The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout
|
||||
each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves
|
||||
the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and
|
||||
discovery refuses a module that uses one. An installed module that is disabled shows a host-owned
|
||||
*This page isn't available* rather than loading anything. A module's own
|
||||
sub-pages, Settings and Account pages, and returning to the workflow a Settings link came from are
|
||||
later Stage 1 work.
|
||||
|
||||
The Dashboard's **shell** follows DESIGN.md §4
|
||||
([ADR 0058](docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)): a
|
||||
collapsible left sidebar — icons and labels expanded, icons with accessible tooltips collapsed, the
|
||||
choice remembered in this browser — with the Overview first, the server's groups and modules below it,
|
||||
and an account menu anchored at the bottom (Sign out, for now). There is no top bar. On a phone the
|
||||
same navigation opens in a modal drawer with focus trapped inside it, Escape and the backdrop closing
|
||||
it, and focus returned to the menu button. Lucide is Ekklesia's icon vocabulary; until modules can
|
||||
declare their own icon (manifest v9), every module shows one generic icon. Church branding in the
|
||||
sidebar's identity slot is still to come.
|
||||
|
||||
`@ekklesia/ui` now carries the interactions every later screen shares
|
||||
([ADR 0059](docs/architecture/decisions/0059-shared-interaction-primitives.md)): a product-owned
|
||||
`ConfirmationDialog` (simple, destructive, or typed-phrase, chosen by the caller), a per-tree
|
||||
top-center toast system, `ErrorState` with collapsed technical detail and remedies, skeleton loading,
|
||||
and `PageHeader` with a Back action that returns only to a validated Dashboard page. Every dialog
|
||||
traps and restores focus, and `pnpm lint` refuses `window.alert`, `confirm`, and `prompt`
|
||||
repository-wide.
|
||||
|
||||
## Quick start
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/api",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"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 { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createDeviceRoutes } from "./device-routes.js";
|
||||
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||
@@ -363,10 +363,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
// own entirely. See `./health-routes.js`.
|
||||
app.route("/", createHealthRoutes(options.health));
|
||||
|
||||
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
|
||||
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
||||
// `./dashboard-host-routes.js`.
|
||||
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
||||
// The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the
|
||||
// page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
|
||||
// `/<module-id>`. They grant no authority and resolve no principal; the page asks
|
||||
// `/api/v1/principal` itself. See `./dashboard-host-routes.js`.
|
||||
app.route(
|
||||
"/",
|
||||
createDashboardHostRoutes(options.dashboardHostAssets, {
|
||||
modulePages: options.moduleLifecycle.snapshot.discoveredModules
|
||||
.filter((module) => module.entrypoints.ui !== undefined)
|
||||
.map((module) => module.manifest.id),
|
||||
}),
|
||||
);
|
||||
|
||||
app.route(
|
||||
"/",
|
||||
@@ -407,6 +415,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
||||
...(options.devices
|
||||
? {
|
||||
renewDeviceCredential: (c: Context) =>
|
||||
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (options.devices) {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { moduleIdSchema } from "@ekklesia/contracts";
|
||||
import { isReservedModuleId } from "@ekklesia/core/modules";
|
||||
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
|
||||
import { Hono } from "hono";
|
||||
import { etagMatches } from "./conditional-request.js";
|
||||
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
|
||||
/**
|
||||
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
|
||||
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
|
||||
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
|
||||
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
||||
* `/api/v1/modules/<module-id>`.
|
||||
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
|
||||
* serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
|
||||
* this server and the shell's router read one list), and at each installed module's `/<module-id>`.
|
||||
* Every one of those first segments is either a reserved platform name or a module id Core accepted,
|
||||
* and Core refuses a module id that is a reserved name, so no module page can shadow `/api`,
|
||||
* `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display
|
||||
* host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still
|
||||
* lands on `/` with a query (ADR 0038).
|
||||
*/
|
||||
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
||||
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
||||
@@ -85,29 +91,73 @@ function fixedAsset(
|
||||
});
|
||||
}
|
||||
|
||||
export interface DashboardHostRouteOptions {
|
||||
/**
|
||||
* The id of every module this process discovered that declares a Dashboard UI — enabled or not,
|
||||
* since a disabled module's bookmark should open the Dashboard and say the module is unavailable
|
||||
* rather than fail as an unknown address. Fixed at startup, like the rest of the module set.
|
||||
*/
|
||||
readonly modulePages: readonly string[];
|
||||
}
|
||||
|
||||
/** Each module page must be a real module id that names no platform namespace, exactly once.
|
||||
* Discovery already guarantees this; the routes refuse to compose rather than trust it. */
|
||||
function validatedModulePages(modulePages: readonly string[]): readonly string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const moduleId of modulePages) {
|
||||
if (
|
||||
!moduleIdSchema.safeParse(moduleId).success ||
|
||||
isReservedModuleId(moduleId) ||
|
||||
DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) ||
|
||||
seen.has(moduleId)
|
||||
) {
|
||||
throw new DashboardHostConfigurationError();
|
||||
}
|
||||
seen.add(moduleId);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
||||
* default same-origin shape.
|
||||
*
|
||||
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
|
||||
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
|
||||
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
|
||||
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
|
||||
* the sign-in form until `/api/v1/principal` says otherwise.
|
||||
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
|
||||
* from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
|
||||
* platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
|
||||
* module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
|
||||
* server change later) — always with the **same** bytes and the same validator, because the path
|
||||
* selects a page inside the shell rather than a file here; that is what lets a refreshed or
|
||||
* bookmarked deep link stay where it was.
|
||||
*
|
||||
* A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not
|
||||
* installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does
|
||||
* `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall through to the
|
||||
* application's ordinary sanitized `404`. Like the display host's code, these bytes grant no
|
||||
* authority: the page renders the sign-in form until `/api/v1/principal` says otherwise.
|
||||
*/
|
||||
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
|
||||
export function createDashboardHostRoutes(
|
||||
assets: DashboardHostAssets,
|
||||
options: DashboardHostRouteOptions,
|
||||
): Hono {
|
||||
const snapshot = snapshotAssets(assets);
|
||||
const modulePages = validatedModulePages(options.modulePages);
|
||||
const routes = new Hono();
|
||||
|
||||
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
|
||||
const page = (ifNoneMatch: string | undefined): Response =>
|
||||
fixedAsset(
|
||||
c.req.header("if-none-match"),
|
||||
ifNoneMatch,
|
||||
snapshot.html,
|
||||
snapshot.htmlEtag,
|
||||
"text/html; charset=utf-8",
|
||||
DASHBOARD_PAGE_SECURITY_HEADERS,
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
|
||||
for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
|
||||
routes.get(`/${name}`, (c) => page(c.req.header("if-none-match")));
|
||||
routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match")));
|
||||
}
|
||||
|
||||
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
||||
fixedAsset(
|
||||
|
||||
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
|
||||
*/
|
||||
function writeDeviceCookie(
|
||||
c: Context,
|
||||
options: DeviceRoutesOptions,
|
||||
options: Pick<DeviceRoutesOptions, "secureCookies">,
|
||||
name: string,
|
||||
value: string,
|
||||
maxAgeSeconds: number,
|
||||
@@ -106,6 +106,27 @@ function writeDeviceCookie(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
|
||||
*
|
||||
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
|
||||
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
|
||||
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
|
||||
*/
|
||||
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
|
||||
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
|
||||
if (credential === undefined || credential.length === 0) {
|
||||
return;
|
||||
}
|
||||
writeDeviceCookie(
|
||||
c,
|
||||
{ secureCookies },
|
||||
DEVICE_SESSION_COOKIE,
|
||||
credential,
|
||||
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
|
||||
);
|
||||
}
|
||||
|
||||
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
const routes = new HonoApp();
|
||||
const now = options.now ?? ((): Date => new Date());
|
||||
|
||||
@@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
|
||||
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||
* surface-owned files before binding and supplies their frozen contents here. */
|
||||
readonly assets?: DisplayHostAssets;
|
||||
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
|
||||
* deployment has device support; called only for a request that resolved to a device. */
|
||||
readonly renewDeviceCredential?: (c: Context) => void;
|
||||
}
|
||||
|
||||
/** A composition error, detected before any display route is registered. Carries no module id,
|
||||
@@ -199,6 +202,11 @@ function mountDisplay(
|
||||
if (!viewer) {
|
||||
return displayFailure(c);
|
||||
}
|
||||
// Every screen asks for this on start, on reconnect, and periodically while running, so it
|
||||
// is where an in-use credential's browser expiry is pushed forward.
|
||||
if (principal.kind === "device") {
|
||||
options.renewDeviceCredential?.(c);
|
||||
}
|
||||
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
||||
} catch {
|
||||
return displayFailure(c);
|
||||
|
||||
@@ -208,6 +208,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
|
||||
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
|
||||
const SHUTDOWN_SETTLE_MS = 3_000;
|
||||
|
||||
/**
|
||||
* The connections left on `databaseName` once a shutdown has had a moment to land.
|
||||
*
|
||||
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
|
||||
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
|
||||
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
|
||||
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
|
||||
*/
|
||||
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
|
||||
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
|
||||
for (;;) {
|
||||
const count = await activeConnectionCount(databaseName);
|
||||
if (count === 0 || Date.now() >= deadline) {
|
||||
return count;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||
const name = makeDatabaseName(label);
|
||||
await createDatabase(name);
|
||||
@@ -1349,7 +1372,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
|
||||
await running.shutdown();
|
||||
|
||||
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
|
||||
const remainingConnections = await activeConnectionCount(database.name);
|
||||
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||
expect(remainingConnections).toBe(0);
|
||||
|
||||
await dropDatabase(database.name);
|
||||
@@ -2230,7 +2253,7 @@ describe.sequential("live API — platform owner and delegated grant management"
|
||||
|
||||
it("shuts down cleanly, closing the one shared pool", async () => {
|
||||
await running.shutdown();
|
||||
const remainingConnections = await activeConnectionCount(database.name);
|
||||
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||
expect(remainingConnections).toBe(0);
|
||||
await dropDatabase(database.name);
|
||||
});
|
||||
@@ -2837,7 +2860,7 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
|
||||
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
|
||||
await running.shutdown();
|
||||
const remainingConnections = await activeConnectionCount(database.name);
|
||||
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||
expect(remainingConnections).toBe(0);
|
||||
// Exactly one stop, matching the exactly-one register/start already asserted above.
|
||||
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleRouteHandler,
|
||||
parseModuleManifest,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -13,9 +14,13 @@ import {
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
isReservedModuleId,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleConfiguration,
|
||||
RESERVED_MODULE_IDS,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -33,11 +38,23 @@ import {
|
||||
DASHBOARD_HOST_PAGE_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
DashboardHostConfigurationError,
|
||||
createDashboardHostRoutes,
|
||||
} from "../src/dashboard-host-routes.js";
|
||||
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
|
||||
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
|
||||
import {
|
||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||
MODULE_DISPLAY_PAGE_PREFIX,
|
||||
} from "../src/module-display-path.js";
|
||||
import {
|
||||
DISPLAY_HOST_SCRIPT_PATH,
|
||||
DISPLAY_SERVICE_WORKER_PATH,
|
||||
} from "../src/module-display-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
|
||||
@@ -78,9 +95,40 @@ function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||
}
|
||||
|
||||
/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id
|
||||
* and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */
|
||||
function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
...(options.ui ? { ui: "./dist/ui/index.js" } : {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
version: "0.1.0",
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: {
|
||||
server: `/modules/${id}/dist/server/index.js`,
|
||||
...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}),
|
||||
},
|
||||
manifest,
|
||||
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
||||
packageName: `@test/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(
|
||||
options: {
|
||||
readonly assets?: DashboardHostAssets;
|
||||
readonly configuration?: ModuleLifecycleConfiguration;
|
||||
readonly discovered?: readonly DiscoveredModule[];
|
||||
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
||||
} = {},
|
||||
) {
|
||||
@@ -100,7 +148,10 @@ function buildApp(
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: fakeModuleServices(),
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
@@ -193,6 +244,169 @@ describe("GET / (the Dashboard page)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard deep links (ADR 0057)", () => {
|
||||
const installed = () => [
|
||||
discoveredModule("signage", { ui: true }),
|
||||
discoveredModule("calendar", { ui: true }),
|
||||
discoveredModule("retired", { ui: true }),
|
||||
// A display-only module has no Dashboard page (ADR 0033).
|
||||
discoveredModule("lobby-screen", { ui: false }),
|
||||
];
|
||||
// Installed but disabled: its address still opens the Dashboard, which says it is unavailable.
|
||||
const configuration = { modules: [{ enabled: false, moduleId: "retired" }] };
|
||||
|
||||
const deepLinks = [
|
||||
"/settings",
|
||||
"/settings/",
|
||||
"/settings/displays/add",
|
||||
"/account",
|
||||
"/account/appearance",
|
||||
"/signage",
|
||||
"/signage/",
|
||||
// Reserved for the module's own pages, which the shell will interpret (ADR 0057).
|
||||
"/signage/timelines",
|
||||
"/signage/timelines/a1b2",
|
||||
"/calendar",
|
||||
"/retired",
|
||||
];
|
||||
|
||||
it("serves the shell's own platform pages, from the shell's own list", () => {
|
||||
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
|
||||
});
|
||||
|
||||
it("serves the identical page, validator, and security headers at every Dashboard page, so a refresh stays put", async () => {
|
||||
const { app } = buildApp({ configuration, discovered: installed() });
|
||||
|
||||
for (const path of deepLinks) {
|
||||
const response = await app.request(path);
|
||||
expect(response.status, path).toBe(200);
|
||||
expect(await response.text(), path).toBe(PAGE_HTML);
|
||||
expect(response.headers.get("content-type"), path).toBe("text/html; charset=utf-8");
|
||||
expect(response.headers.get("cache-control"), path).toBe("no-cache");
|
||||
expect(response.headers.get("etag"), path).toBe(PAGE_ETAG);
|
||||
expectPageSecurityHeaders(response);
|
||||
}
|
||||
expect(readFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revalidates a deep link with the page's one validator", async () => {
|
||||
const { app } = buildApp({ discovered: installed() });
|
||||
|
||||
for (const path of ["/settings/displays", "/signage"]) {
|
||||
const response = await app.request(path, { headers: { "If-None-Match": PAGE_ETAG } });
|
||||
expect(response.status, path).toBe(304);
|
||||
expect(await response.text(), path).toBe("");
|
||||
expectPageSecurityHeaders(response);
|
||||
}
|
||||
});
|
||||
|
||||
it("never turns an arbitrary top-level path into a Dashboard page", async () => {
|
||||
const { app } = buildApp({ configuration, discovered: installed() });
|
||||
|
||||
for (const path of [
|
||||
"/definitely-not-a-real-module",
|
||||
"/definitely-not-a-real-module/timelines",
|
||||
// Installed, but with no Dashboard UI.
|
||||
"/lobby-screen",
|
||||
// Look-alikes of real pages.
|
||||
"/signages",
|
||||
"/signage-old",
|
||||
"/Signage",
|
||||
"/settings-old",
|
||||
"/accounts",
|
||||
// The former implementation prefix is not a namespace at all.
|
||||
"/modules",
|
||||
"/modules/signage",
|
||||
]) {
|
||||
await expectSanitizedNotFound(await app.request(path), path);
|
||||
}
|
||||
});
|
||||
|
||||
it("serves a module's page only while that module is installed", async () => {
|
||||
const { app } = buildApp({ discovered: [] });
|
||||
|
||||
await expectSanitizedNotFound(await app.request("/signage"), "/signage");
|
||||
expect((await app.request("/settings")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("serves the page and never the script, whatever a deep link's last segment looks like", async () => {
|
||||
const { app } = buildApp({ discovered: installed() });
|
||||
|
||||
for (const path of [
|
||||
"/signage/app.js",
|
||||
"/settings/assets/ekklesia-dashboard/app.js",
|
||||
"/account/index.html",
|
||||
]) {
|
||||
expect(await (await app.request(path)).text(), path).toBe(PAGE_HTML);
|
||||
}
|
||||
});
|
||||
|
||||
it("answers no other method at a Dashboard page", async () => {
|
||||
const { app } = buildApp({ discovered: installed() });
|
||||
|
||||
for (const path of ["/settings/displays", "/signage"]) {
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses at composition a module page that would shadow a platform namespace", () => {
|
||||
for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) {
|
||||
expect(
|
||||
() =>
|
||||
createDashboardHostRoutes(createDashboardHostAssets(PAGE_HTML, SCRIPT), { modulePages }),
|
||||
JSON.stringify(modulePages),
|
||||
).toThrow(DashboardHostConfigurationError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the platform's reserved top-level names (ADR 0057)", () => {
|
||||
it("cover every top-level namespace the API registers, so a module page can never shadow one", () => {
|
||||
const { app } = buildApp({ discovered: [discoveredModule("signage", { ui: true })] });
|
||||
|
||||
const topLevel = new Set(
|
||||
app.routes
|
||||
.map((route) => route.path.split("/")[1] ?? "")
|
||||
.filter((segment) => segment !== "" && segment !== "*"),
|
||||
);
|
||||
topLevel.delete("signage");
|
||||
|
||||
// This composition publishes no display, so the display host's routes are absent here; they
|
||||
// are checked through their own path constants below.
|
||||
expect([...topLevel].sort()).toEqual(["account", "api", "assets", "settings"]);
|
||||
for (const segment of topLevel) {
|
||||
expect(isReservedModuleId(segment), segment).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("cover the display host's pages, service worker, and script, and the Dashboard's script", () => {
|
||||
for (const path of [
|
||||
MODULE_DISPLAY_PAGE_PREFIX,
|
||||
DISPLAY_SERVICE_WORKER_PATH,
|
||||
DISPLAY_HOST_SCRIPT_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||
]) {
|
||||
expect(isReservedModuleId(path.split("/")[1] ?? ""), path).toBe(true);
|
||||
}
|
||||
expect([...RESERVED_MODULE_IDS].sort()).toEqual([
|
||||
"account",
|
||||
"api",
|
||||
"assets",
|
||||
"display",
|
||||
"settings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("include every Dashboard platform page the shell interprets", () => {
|
||||
for (const page of DASHBOARD_PLATFORM_PAGES) {
|
||||
expect(RESERVED_MODULE_IDS, page).toContain(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
@@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
|
||||
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
|
||||
*/
|
||||
describe("credential lifetime", () => {
|
||||
const ttlSeconds = 180 * 24 * 60 * 60;
|
||||
const descriptor = "/api/v1/module-displays/announcements";
|
||||
|
||||
function renewal(response: Response): string | undefined {
|
||||
return setCookieFor(response, "ekklesia_device_session");
|
||||
}
|
||||
|
||||
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
|
||||
const running = await platform("renewal");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
await pairDevice(running, device);
|
||||
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
|
||||
await query(
|
||||
running.url,
|
||||
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
|
||||
);
|
||||
|
||||
const response = await device.request(descriptor);
|
||||
expect(response.status).toBe(200);
|
||||
// The same credential, re-issued with a full lifetime and the same protections.
|
||||
const header = renewal(response);
|
||||
expect(header).toBeDefined();
|
||||
expect(header).toContain(`ekklesia_device_session=${credential};`);
|
||||
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
|
||||
expect(header).toMatch(/HttpOnly/u);
|
||||
expect(header).toMatch(/SameSite=Strict/u);
|
||||
expect(header).toMatch(/Path=\//u);
|
||||
expect(header).not.toMatch(/Domain=/iu);
|
||||
|
||||
// Using it moved the server's window forward too.
|
||||
const [row] = await query<{ recent: boolean }>(
|
||||
running.url,
|
||||
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
|
||||
);
|
||||
expect(row?.recent).toBe(true);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
|
||||
const running = await platform("legacy");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
await pairDevice(running, device);
|
||||
// A production screen paired 179 days ago that checked in this morning: under the old
|
||||
// rule it had one day left. It must carry straight on.
|
||||
await query(
|
||||
running.url,
|
||||
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
|
||||
);
|
||||
expect((await device.request(descriptor)).status).toBe(200);
|
||||
await query(
|
||||
running.url,
|
||||
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
|
||||
);
|
||||
expect((await device.request(descriptor)).status).toBe(200);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
|
||||
const running = await platform("abandoned");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
await pairDevice(running, device);
|
||||
await query(
|
||||
running.url,
|
||||
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
|
||||
);
|
||||
const response = await device.request(descriptor);
|
||||
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
|
||||
expect(response.status).toBe(403);
|
||||
expect(renewal(response)).toBeUndefined();
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("never renews a revoked screen", async () => {
|
||||
const running = await platform("revoked-renewal");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
await pairDevice(running, device);
|
||||
const listed = (await (
|
||||
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||
).json()) as { devices: Array<{ id: string }> };
|
||||
await adminRequest(
|
||||
running.origin,
|
||||
running.ownerCookie,
|
||||
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
|
||||
{},
|
||||
);
|
||||
const response = await device.request(descriptor);
|
||||
expect(response.status).toBe(403);
|
||||
expect(renewal(response)).toBeUndefined();
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not hand a device cookie to a person viewing the display", async () => {
|
||||
const running = await platform("human-renewal");
|
||||
try {
|
||||
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
|
||||
expect(response.status).toBe(200);
|
||||
expect(renewal(response)).toBeUndefined();
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
||||
const running = await platform("mixed");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
|
||||
@@ -195,6 +195,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
|
||||
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
|
||||
const SHUTDOWN_SETTLE_MS = 3_000;
|
||||
|
||||
/**
|
||||
* The connections left on `databaseName` once a shutdown has had a moment to land.
|
||||
*
|
||||
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
|
||||
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
|
||||
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
|
||||
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
|
||||
*/
|
||||
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
|
||||
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
|
||||
for (;;) {
|
||||
const count = await activeConnectionCount(databaseName);
|
||||
if (count === 0 || Date.now() >= deadline) {
|
||||
return count;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||
const name = makeDatabaseName(label);
|
||||
await createDatabase(name);
|
||||
@@ -760,7 +783,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
||||
|
||||
await running.shutdown();
|
||||
|
||||
const remainingConnections = await activeConnectionCount(database.name);
|
||||
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||
expect(remainingConnections).toBe(0);
|
||||
|
||||
const eventsAfterShutdown = readRecorder();
|
||||
|
||||
+4
-1
@@ -19,7 +19,10 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended"
|
||||
"preset": "recommended",
|
||||
"suspicious": {
|
||||
"noAlert": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/core",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -25,6 +25,7 @@ class VerifyArgumentsError extends Error {}
|
||||
|
||||
const USAGE =
|
||||
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
|
||||
export const MODULE_VERIFIER_SCRATCH_ENV = "EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR";
|
||||
|
||||
export interface VerifyModulePackageArguments {
|
||||
readonly archivePath: string;
|
||||
@@ -90,6 +91,16 @@ export function parseSha256File(contents: string): string {
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
/** The CLI's operator-facing bridge to Core's otherwise explicit verifier option. */
|
||||
export function moduleVerifierScratchDirectory(environment: NodeJS.ProcessEnv): string | undefined {
|
||||
const configured = environment[MODULE_VERIFIER_SCRATCH_ENV];
|
||||
if (configured === undefined) return undefined;
|
||||
if (configured === "" || !path.isAbsolute(configured)) {
|
||||
throw new VerifyArgumentsError(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
async function readJson(file: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
@@ -121,6 +132,7 @@ export async function readModulePackagePlatform(
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
|
||||
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
|
||||
let expectedSha256 = parsed.expectedSha256;
|
||||
if (parsed.sha256File !== undefined) {
|
||||
let contents: string;
|
||||
@@ -137,6 +149,7 @@ async function main(): Promise<void> {
|
||||
const result = await verifyModulePackage({
|
||||
archivePath: parsed.archivePath,
|
||||
platform,
|
||||
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
|
||||
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
|
||||
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
|
||||
});
|
||||
|
||||
@@ -44,17 +44,26 @@ export class DeviceStoreError extends Error {
|
||||
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* How long a device credential is accepted before the device must pair again.
|
||||
* How long a device credential may go **unused** before the device must pair again (ADR 0056).
|
||||
*
|
||||
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
|
||||
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
|
||||
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
|
||||
* decides on every single request whether the device is still revoked, so an administrator's revoke
|
||||
* takes effect immediately regardless of how long the credential would otherwise have lived. The
|
||||
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
|
||||
* The window slides: every request from the device moves `lastSeenAt` forward, and the host
|
||||
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
|
||||
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
|
||||
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops
|
||||
* being an identity at all: 180 days after it was last seen, its credential is dead.
|
||||
*/
|
||||
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
||||
|
||||
/** When a device credential expires: the idle window, measured from the last time the device was
|
||||
* seen, or from pairing if it never has been. */
|
||||
export function deviceCredentialExpiresAt(input: {
|
||||
readonly createdAt: Date;
|
||||
readonly lastSeenAt?: Date | null | undefined;
|
||||
}): Date {
|
||||
const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0);
|
||||
return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS);
|
||||
}
|
||||
|
||||
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
||||
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
||||
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
||||
@@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
||||
}
|
||||
// The credential's own horizon, measured from when the device was paired. Revocation is
|
||||
// checked above and is what makes a long horizon acceptable.
|
||||
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
|
||||
// Expiry is measured from last use, not from pairing, so a screen in use never ages out.
|
||||
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
safeParseModuleManifest,
|
||||
semanticVersionSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import { isReservedModuleId } from "./reserved-ids.js";
|
||||
|
||||
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
||||
|
||||
@@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode =
|
||||
| "MODULE_ENTRYPOINT_INVALID"
|
||||
| "MODULE_ENTRYPOINT_MISSING"
|
||||
| "MODULE_ID_DUPLICATE"
|
||||
| "MODULE_ID_RESERVED"
|
||||
| "MODULE_MANIFEST_INVALID"
|
||||
| "MODULE_MANIFEST_MISSING"
|
||||
| "MODULE_PACKAGE_INVALID"
|
||||
@@ -614,6 +616,18 @@ async function scanModuleDirectory(
|
||||
});
|
||||
}
|
||||
|
||||
if (isReservedModuleId(manifest.id)) {
|
||||
// A module's Dashboard page is `/<module-id>` (ADR 0057), in the same URL space as the
|
||||
// platform's own top-level namespaces.
|
||||
issues.push({
|
||||
code: "MODULE_ID_RESERVED",
|
||||
message: `Module ID ${manifest.id} is reserved: the platform uses /${manifest.id} for its own pages and routes.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: "id",
|
||||
});
|
||||
}
|
||||
|
||||
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||
if (!artifacts) {
|
||||
return undefined;
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from "./module-package-host.js";
|
||||
export * from "./module-package-verification.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./realtime.js";
|
||||
export * from "./reserved-ids.js";
|
||||
export * from "./runtime.js";
|
||||
export * from "./schema-discovery.js";
|
||||
export * from "./ui-publication.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fork } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -73,7 +73,8 @@ export type ModulePackageVerificationErrorCode =
|
||||
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
|
||||
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED";
|
||||
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
|
||||
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
|
||||
|
||||
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
|
||||
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
|
||||
@@ -118,6 +119,8 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
|
||||
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
|
||||
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
|
||||
"The package metadata contains transitional facts this platform does not understand.",
|
||||
MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE:
|
||||
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
|
||||
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
|
||||
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
|
||||
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
|
||||
@@ -157,6 +160,12 @@ export interface VerifyModulePackageOptions {
|
||||
readonly platform: ModulePackagePlatform;
|
||||
/** A directory that must not exist yet; the verified module directory is written there. */
|
||||
readonly extractTo?: string;
|
||||
/**
|
||||
* Existing writable directory where backend candidates may execute. Defaults to `os.tmpdir()`.
|
||||
* A hardened caller whose system temporary directory is `noexec` must provide a dedicated
|
||||
* executable scratch mount; each verification creates and removes its own child directory.
|
||||
*/
|
||||
readonly executionScratchDirectory?: string;
|
||||
readonly loadTimeoutMs?: number;
|
||||
}
|
||||
|
||||
@@ -414,12 +423,66 @@ function probeEntrypointPath(): string {
|
||||
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
|
||||
}
|
||||
|
||||
function decodedMountPath(value: string): string {
|
||||
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) =>
|
||||
String.fromCharCode(Number.parseInt(octal, 8)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Linux exposes the effective options of every mount, including a container's tmpfs mounts. A
|
||||
* verifier cannot infer `noexec` from file permissions: native addons are mapped executable rather
|
||||
* than invoked as files. On systems without mountinfo, the load remains the final authority. */
|
||||
async function isNoExecMount(directory: string): Promise<boolean> {
|
||||
if (process.platform !== "linux") return false;
|
||||
let mountinfo: string;
|
||||
let canonicalDirectory: string;
|
||||
try {
|
||||
[mountinfo, canonicalDirectory] = await Promise.all([
|
||||
readFile("/proc/self/mountinfo", "utf8"),
|
||||
realpath(directory),
|
||||
]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const mounts = mountinfo
|
||||
.split("\n")
|
||||
.map((line) => line.split(" "))
|
||||
.filter((fields) => fields.length > 6 && fields.includes("-"))
|
||||
.map((fields) => ({
|
||||
mountPoint: decodedMountPath(fields[4] ?? ""),
|
||||
options: (fields[5] ?? "").split(","),
|
||||
}))
|
||||
.filter(
|
||||
({ mountPoint }) =>
|
||||
canonicalDirectory === mountPoint ||
|
||||
canonicalDirectory.startsWith(mountPoint === "/" ? "/" : `${mountPoint}/`),
|
||||
)
|
||||
.sort((left, right) => right.mountPoint.length - left.mountPoint.length);
|
||||
return mounts[0]?.options.includes("noexec") ?? false;
|
||||
}
|
||||
|
||||
async function createExecutionScratch(baseDirectory: string): Promise<string> {
|
||||
if (!path.isAbsolute(baseDirectory)) {
|
||||
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||
}
|
||||
try {
|
||||
if (await isNoExecMount(baseDirectory)) {
|
||||
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||
}
|
||||
return await mkdtemp(path.join(baseDirectory, "ekklesia-package-verify-"));
|
||||
} catch (error) {
|
||||
if (error instanceof ModulePackageVerificationError) throw error;
|
||||
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
|
||||
* only whether each loaded and exported a definition naming this module. */
|
||||
async function loadEntrypoints(
|
||||
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
|
||||
entrypoints: Readonly<Record<string, string>>,
|
||||
timeoutMs: number,
|
||||
executionDirectory: string,
|
||||
): Promise<void> {
|
||||
const outcome = await new Promise<"ok" | "failed">((resolve) => {
|
||||
let settled = false;
|
||||
@@ -445,7 +508,7 @@ async function loadEntrypoints(
|
||||
}),
|
||||
],
|
||||
{
|
||||
cwd: tmpdir(),
|
||||
cwd: executionDirectory,
|
||||
env: {},
|
||||
execArgv: [],
|
||||
serialization: "json",
|
||||
@@ -514,7 +577,7 @@ export async function verifyModulePackage(
|
||||
const manifest = checkIdentity(archive, metadata, files);
|
||||
checkCompatibility(metadata, manifest, files, options.platform);
|
||||
|
||||
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-"));
|
||||
const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
|
||||
try {
|
||||
// The candidate alone, in a scratch inventory of its own: never module storage.
|
||||
const modulesDirectory = path.join(scratch, "candidates");
|
||||
@@ -550,7 +613,7 @@ export async function verifyModulePackage(
|
||||
|
||||
// Executed only now, and first in isolation: a schema is imported in this process below only
|
||||
// after the probe proved it loads on its own.
|
||||
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
|
||||
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
|
||||
try {
|
||||
await discoverModuleSchemas(discoveryOptions);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Top-level names the platform owns, which therefore can never be a module id (ADR 0057).
|
||||
*
|
||||
* Every module with a Dashboard UI is addressed at `/<module-id>`, beside the platform's own
|
||||
* top-level namespaces in one URL space. A module called `settings` would collide with the
|
||||
* Dashboard's Settings pages, and one called `api` with the API itself. This is the complete set the
|
||||
* platform serves today, not a guess at what it might want later:
|
||||
*
|
||||
* - `api` — the API: identity, principal, health, Core resources, module routes, module UI artifacts,
|
||||
* display descriptors, and realtime (`/api/…`);
|
||||
* - `assets` — the Dashboard's and display host's scripts (`/assets/ekklesia-dashboard/…`,
|
||||
* `/assets/ekklesia-display/…`);
|
||||
* - `display` — the display host's pages and service worker (`/display/…`);
|
||||
* - `settings` and `account` — the Dashboard's own Settings and account areas.
|
||||
*
|
||||
* `apps/api` holds the list to the routes it actually registers, and to the Dashboard namespaces the
|
||||
* shell interprets, so a new top-level platform route cannot be added without reserving it here.
|
||||
* Reserving a name is a breaking change for any installation with a module of that id, so the set
|
||||
* grows only deliberately.
|
||||
*/
|
||||
export const RESERVED_MODULE_IDS = Object.freeze([
|
||||
"account",
|
||||
"api",
|
||||
"assets",
|
||||
"display",
|
||||
"settings",
|
||||
] as const);
|
||||
|
||||
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);
|
||||
|
||||
export function isReservedModuleId(moduleId: string): boolean {
|
||||
return reserved.has(moduleId);
|
||||
}
|
||||
@@ -169,13 +169,41 @@ describe("the browser shell reaches none of it", () => {
|
||||
});
|
||||
|
||||
it("never puts a reset token in browser storage", async () => {
|
||||
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
|
||||
// A reset token is a bearer credential for one account; the shell holds it in React state for
|
||||
// the life of one form and nowhere else. The shell's one use of browser storage is the desktop
|
||||
// sidebar's expanded/collapsed mode (ADR 0058), so that use is pinned exactly: `localStorage` is
|
||||
// named only by the entry point, and only to hand it to the sidebar preference, whose one write
|
||||
// is its own fixed key and a validated mode.
|
||||
const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src"));
|
||||
const writes: string[] = [];
|
||||
for (const file of files) {
|
||||
const code = withoutComments(file.text);
|
||||
// A reset token is a bearer credential for one account; the shell holds it in React state for
|
||||
// the life of one form and nowhere else.
|
||||
expect(code, file.path).not.toContain("localStorage");
|
||||
expect(code, file.path).not.toContain("sessionStorage");
|
||||
expect(code, file.path).not.toContain("document.cookie");
|
||||
writes.push(
|
||||
...[...code.matchAll(/\.setItem\(([^)]*)\)/gu)].map(
|
||||
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
|
||||
),
|
||||
);
|
||||
if (file.path.endsWith(path.join("browser", "main.tsx"))) {
|
||||
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
|
||||
expect(code).toMatch(
|
||||
/createLocalSidebarPreference\(\s*\(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
|
||||
);
|
||||
} else {
|
||||
expect(code, file.path).not.toContain("localStorage");
|
||||
}
|
||||
}
|
||||
expect(writes).toEqual([
|
||||
`${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`,
|
||||
]);
|
||||
// The files that ever hold the token touch no storage API at all.
|
||||
for (const file of files.filter((candidate) =>
|
||||
["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) =>
|
||||
candidate.path.endsWith(name),
|
||||
),
|
||||
)) {
|
||||
expect(withoutComments(file.text), file.path).not.toMatch(/\b(?:setItem|getItem|Storage)\b/u);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
discoverModules,
|
||||
isReservedModuleId,
|
||||
ModuleDiscoveryError,
|
||||
type ModuleDiscoveryIssueCode,
|
||||
RESERVED_MODULE_IDS,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
interface ModuleFixtureOptions {
|
||||
@@ -271,6 +273,52 @@ describe("module discovery", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a module whose id is one of the platform's reserved top-level names (ADR 0057)", async () => {
|
||||
// Every module with a Dashboard UI is addressed at `/<module-id>`, so an id equal to a
|
||||
// platform-owned top-level namespace would collide with the API, the display host, a host
|
||||
// script, or a Dashboard page.
|
||||
expect(RESERVED_MODULE_IDS).toEqual(["account", "api", "assets", "display", "settings"]);
|
||||
expect(Object.isFrozen(RESERVED_MODULE_IDS)).toBe(true);
|
||||
|
||||
for (const id of RESERVED_MODULE_IDS) {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, id);
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
|
||||
expect(error.issues, id).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "MODULE_ID_RESERVED",
|
||||
moduleDirectory: id,
|
||||
moduleId: id,
|
||||
path: "id",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts ids that merely resemble a reserved name", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
for (const id of ["settings-sync", "my-account", "displays", "api-docs"]) {
|
||||
await createModule(modulesDirectory, id);
|
||||
}
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
expect(modules.map((module) => module.manifest.id).sort()).toEqual([
|
||||
"api-docs",
|
||||
"displays",
|
||||
"my-account",
|
||||
"settings-sync",
|
||||
]);
|
||||
expect(isReservedModuleId("settings")).toBe(true);
|
||||
expect(isReservedModuleId("settings-sync")).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces platform and Module SDK compatibility", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "future", {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -48,6 +48,12 @@ async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
|
||||
return { archivePath, sha256: fixture.sha256 };
|
||||
}
|
||||
|
||||
async function executionScratch(): Promise<string> {
|
||||
const directory = path.join(await scratch(), "execution");
|
||||
await mkdir(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function rejection(
|
||||
options: PackageFixtureOptions,
|
||||
code: ModulePackageVerificationErrorCode,
|
||||
@@ -100,6 +106,52 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an explicit execution scratch for pure JavaScript and cleans concurrent runs", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const executionScratchDirectory = await executionScratch();
|
||||
const runs = Array.from({ length: 4 }, () =>
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
);
|
||||
await expect(Promise.all(runs)).resolves.toHaveLength(4);
|
||||
expect(await readdir(executionScratchDirectory)).toEqual([]);
|
||||
});
|
||||
|
||||
it("cleans its execution scratch after a backend load failure", async () => {
|
||||
const { archivePath } = await archiveFile({
|
||||
serverSource:
|
||||
'import "dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0" };\n',
|
||||
});
|
||||
const executionScratchDirectory = await executionScratch();
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
).rejects.toMatchObject({ code: "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED" });
|
||||
expect(await readdir(executionScratchDirectory)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a missing execution scratch as a verifier environment error", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const executionScratchDirectory = path.join(await scratch(), "missing");
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
).rejects.toMatchObject({
|
||||
code: "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE",
|
||||
message:
|
||||
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
|
||||
});
|
||||
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("rejects an unsafe archive before consulting the execution scratch", async () => {
|
||||
const { archivePath } = await archiveFile(
|
||||
withEntry({ path: "example/../attacker-chosen", data: "x" }),
|
||||
);
|
||||
const executionScratchDirectory = path.join(await scratch(), "missing");
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
).rejects.toMatchObject({ code: "MODULE_PACKAGE_UNSAFE_PATH" });
|
||||
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("extracts only a verified package, with normalized modes, into a new directory", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const target = path.join(await scratch(), "example");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
EMPTY_MODULE_STORAGE,
|
||||
isModuleStorageError,
|
||||
isValidModuleStorageKey,
|
||||
MODULE_STORAGE_KEY_MAX_BYTES,
|
||||
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
|
||||
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes a storage error from an installed module's separate package copy", () => {
|
||||
const external = Object.assign(new Error("The storage service could not be reached."), {
|
||||
code: "STORAGE_PROVIDER_UNAVAILABLE",
|
||||
name: "ModuleStorageError",
|
||||
});
|
||||
|
||||
expect(external).not.toBeInstanceOf(ModuleStorageError);
|
||||
expect(isModuleStorageError(external)).toBe(true);
|
||||
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("gives a module no way to reach another module's storage by knowing its name", async () => {
|
||||
const shared = fakeProvider();
|
||||
const announcements = createModuleStorage({
|
||||
|
||||
@@ -3,6 +3,8 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MODULE_VERIFIER_SCRATCH_ENV,
|
||||
moduleVerifierScratchDirectory,
|
||||
parseSha256File,
|
||||
parseVerifyModulePackageArguments,
|
||||
readModulePackagePlatform,
|
||||
@@ -57,6 +59,21 @@ describe("verify-module-package digest files", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("verify-module-package execution scratch", () => {
|
||||
it("accepts an absolute operator-configured directory and otherwise leaves the library default", () => {
|
||||
expect(moduleVerifierScratchDirectory({})).toBeUndefined();
|
||||
expect(
|
||||
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: "/run/ekklesia-verifier" }),
|
||||
).toBe("/run/ekklesia-verifier");
|
||||
});
|
||||
|
||||
it.each(["", "relative/path"])("refuses the invalid configured path %j", (configured) => {
|
||||
expect(() =>
|
||||
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: configured }),
|
||||
).toThrow(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verify-module-package platform facts", () => {
|
||||
it("reads this platform's versions and its transitional database runtime from the repository", async () => {
|
||||
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
|
||||
|
||||
@@ -42,9 +42,16 @@
|
||||
|
||||
{$EKKLESIA_HOSTNAME} {
|
||||
# Everything the API serves: the Dashboard at /, the API, display pages and their service
|
||||
# worker, and the Dashboard and display host scripts. Any other path is refused here.
|
||||
# worker, and the Dashboard and display host scripts.
|
||||
@ekklesia path / /api/* /display/* /assets/*
|
||||
|
||||
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
|
||||
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
|
||||
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
|
||||
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
|
||||
# Any other path is refused here.
|
||||
@dashboardPage path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
|
||||
|
||||
# Module realtime is Server-Sent Events: flush every event immediately.
|
||||
@realtime path /api/v1/module-realtime/*
|
||||
|
||||
@@ -63,6 +70,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
handle @dashboardPage {
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
|
||||
@@ -51,12 +51,28 @@
|
||||
{$EKKLESIA_DASHBOARD_HOSTNAME} {
|
||||
@dashboard path / /assets/ekklesia-dashboard/*
|
||||
|
||||
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
|
||||
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
|
||||
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
|
||||
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
|
||||
# The API's own namespaces stay refused on this hostname.
|
||||
@dashboardPage {
|
||||
path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
|
||||
not path /api /api/* /display /display/* /assets /assets/*
|
||||
}
|
||||
|
||||
handle @dashboard {
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
}
|
||||
}
|
||||
|
||||
handle @dashboardPage {
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
|
||||
+30
-6
@@ -17,6 +17,7 @@ specific to one organization.
|
||||
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
|
||||
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
|
||||
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
|
||||
| [`display-kiosk.md`](display-kiosk.md) | Setting up an unattended display screen: launch, pairing, and recovery. |
|
||||
|
||||
## The reference deployment
|
||||
|
||||
@@ -170,9 +171,20 @@ identity, target, and Node.js range against this platform, runs the platform's m
|
||||
validation, and loads the backend once in a short-lived process with no environment. Only then does
|
||||
it write the verified module directory, to a new directory outside module storage:
|
||||
|
||||
The backend probe executes the package's server-side code; the verifier container is its isolation
|
||||
boundary. Keep the general-purpose `/tmp` mount `noexec`, and give only this one-shot verifier a
|
||||
separate executable scratch tmpfs. The mount below is ephemeral, bounded at 1100 MiB (just above the
|
||||
package format's 1 GiB expanded limit), `nodev`, `nosuid`, and writable only by the image's
|
||||
unprivileged `node` user. `EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR` tells the CLI where that mount is;
|
||||
the verifier creates a unique child for each run and removes it on success or failure.
|
||||
|
||||
```sh
|
||||
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
|
||||
docker run --rm --network none --read-only --tmpfs /tmp \
|
||||
docker run --rm --network none --read-only \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
|
||||
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
|
||||
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
|
||||
@@ -229,6 +241,14 @@ Module storage is installation state: back it up with the database and the bucke
|
||||
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
|
||||
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||
|
||||
**Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with
|
||||
the new `Caddyfile.example` (or `Caddyfile.split.example`). Each gains a `@dashboardPage` matcher
|
||||
and `handle` block that forward every top-level path shaped like a module id — `/settings`,
|
||||
`/account`, and each installed module's page such as `/signage` — to the API, which answers with
|
||||
the Dashboard page where one exists and its sanitized `404` elsewhere. Without it the Dashboard still
|
||||
opens at `/`, but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the
|
||||
proxy after editing it. Installing or removing a module needs no proxy change.
|
||||
|
||||
### Rolling back
|
||||
|
||||
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
|
||||
@@ -251,7 +271,7 @@ The same image serves both; only configuration differs.
|
||||
|
||||
| Hostname | Serves | Refuses (`404`) |
|
||||
| --- | --- | --- |
|
||||
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
||||
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, `/assets/ekklesia-dashboard/*`, and every module-shaped top-level path (the Dashboard's pages, such as `/settings` and `/signage`; the API answers the rest with its `404`) | everything else, including `/api/*`, `/display/*`, and `/assets/*` |
|
||||
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
|
||||
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
|
||||
|
||||
@@ -327,7 +347,11 @@ networks:
|
||||
|
||||
Then make your proxy do what `Caddyfile.example` does:
|
||||
|
||||
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
|
||||
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`,
|
||||
and every path whose first segment is shaped like a module id, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$` (ADR 0057): that is
|
||||
`/settings`, `/account`, and each installed module's page such as `/signage`, and the API answers
|
||||
anything that is not a real page with its sanitized `404`. Without it, refreshing any Dashboard page
|
||||
other than `/` is your proxy's `404`. Do not list module ids: they change as modules are installed;
|
||||
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
|
||||
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
|
||||
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
|
||||
@@ -393,7 +417,7 @@ deployment:
|
||||
|
||||
| # | Item | Where it is met |
|
||||
| --- | --- | --- |
|
||||
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
|
||||
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*`, and every module-shaped top-level path (the Dashboard's pages) on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
|
||||
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
|
||||
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
|
||||
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
|
||||
@@ -500,10 +524,10 @@ finds its files:
|
||||
```sh
|
||||
docker build \
|
||||
--file deploy/Dockerfile \
|
||||
--build-arg VERSION=0.2.1 \
|
||||
--build-arg VERSION=0.2.5 \
|
||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||
--tag ekklesia:0.2.1 \
|
||||
--tag ekklesia:0.2.5 \
|
||||
.
|
||||
```
|
||||
|
||||
|
||||
@@ -132,6 +132,10 @@ services:
|
||||
command:
|
||||
- mini
|
||||
- -dir=/data
|
||||
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
|
||||
# volume size leaves modest hosts with room for only a few collections; after that, writes
|
||||
# stall instead of failing. This changes allocation granularity, not object or total capacity.
|
||||
- -master.volumeSizeLimitMB=32
|
||||
- -s3.port.https=8443
|
||||
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
||||
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Setting up a display kiosk
|
||||
|
||||
A display is a screen — a television with a small computer, or a mini PC on a wall — that opens one
|
||||
module's display page full screen and keeps it open unattended. This guide sets one up so it starts
|
||||
on boot, stays paired, recovers from network and server outages on its own, and never opens a second
|
||||
copy of itself.
|
||||
|
||||
Pairing and display identity belong to the platform (ADR 0036). Today an administrator approves a
|
||||
screen in the Dashboard sidebar's **Display devices** panel; a later release moves this to
|
||||
**Settings → Displays**. Nothing below changes when that happens.
|
||||
|
||||
## What the screen needs
|
||||
|
||||
- Chromium or Google Chrome, current stable. The display page is tested against Chromium.
|
||||
- A **dedicated, persistent browser profile directory**. The device credential is an HttpOnly cookie
|
||||
in that profile; it is what keeps the screen paired across reboots. Never use a guest, incognito, or
|
||||
"clear data on exit" profile, and never share the profile with a person's browsing.
|
||||
- Network access to the installation's **API hostname** (the display page, its realtime stream, and
|
||||
its module requests) and to the storage hostname that serves images.
|
||||
- A clock that is roughly right. Playback follows the server's clock, but TLS does not tolerate a
|
||||
wildly wrong one.
|
||||
|
||||
## The address
|
||||
|
||||
Every display-capable module has one display page on the API hostname:
|
||||
|
||||
```text
|
||||
https://<api-hostname>/display/<module-id>
|
||||
```
|
||||
|
||||
For Signage that is `https://<api-hostname>/display/signage`. The Signage **Displays** tab shows the
|
||||
exact address under **Copy setup address**. Open it on the screen itself; opening it on an
|
||||
administrator's own computer only previews the display (and that browser appears in the roster as a
|
||||
*Signed-in browser*, which can be removed once it is offline).
|
||||
|
||||
## Launching the browser
|
||||
|
||||
Run exactly **one** launcher, and launch the page as an app window with a fixed profile:
|
||||
|
||||
```bash
|
||||
chromium \
|
||||
--user-data-dir=/var/lib/display-kiosk/profile \
|
||||
--app=https://<api-hostname>/display/signage \
|
||||
--kiosk \
|
||||
--no-first-run \
|
||||
--noerrdialogs \
|
||||
--disable-session-crashed-bubble \
|
||||
--hide-crash-restore-bubble \
|
||||
--disable-features=Translate \
|
||||
--autoplay-policy=no-user-gesture-required \
|
||||
--check-for-update-interval=31536000
|
||||
```
|
||||
|
||||
- `--app` opens the page with no tab strip and no address bar. `--kiosk` makes it full screen.
|
||||
- `--user-data-dir` is the persistent profile that holds the pairing. Keep it on persistent storage
|
||||
and back it up with nothing: if it is lost, the screen simply shows a new pairing code.
|
||||
- Do **not** add `--restore-last-session`, and do not also start the browser from a desktop autostart
|
||||
entry. Chromium allows one process per profile; a second launch hands its URL to the running
|
||||
process, which opens a **second window or tab** of the display. That is the usual cause of
|
||||
"duplicate" displays. One launcher, one profile, one window.
|
||||
|
||||
### Start on boot and restart on exit (systemd)
|
||||
|
||||
Run the browser as an unprivileged user in that user's graphical session, from a single user unit:
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/display-kiosk.service
|
||||
[Unit]
|
||||
Description=Display kiosk
|
||||
After=graphical-session.target network-online.target
|
||||
Wants=network-online.target
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/chromium --user-data-dir=%h/.local/share/display-kiosk --app=https://<api-hostname>/display/signage --kiosk --no-first-run --noerrdialogs --disable-session-crashed-bubble --hide-crash-restore-bubble --disable-features=Translate --autoplay-policy=no-user-gesture-required --check-for-update-interval=31536000
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now display-kiosk.service
|
||||
loginctl enable-linger <kiosk-user> # with the desktop set to log this user in automatically
|
||||
```
|
||||
|
||||
`systemctl --user stop display-kiosk` closes the browser cleanly, which is what keeps its profile —
|
||||
and the pairing cookie — intact. Avoid cutting power to a running kiosk when you can: a clean shutdown
|
||||
is always safer for a browser profile.
|
||||
|
||||
## Pairing
|
||||
|
||||
1. Start the kiosk. With no credential it shows a **six-digit pairing code**.
|
||||
2. In the Dashboard, enter the code under **Display devices**, give the screen a name, and approve.
|
||||
3. Within a few seconds the screen collects its credential and starts showing content.
|
||||
4. In Signage → **Displays**, name the display and choose its group and audience.
|
||||
|
||||
A paired screen stays paired across browser restarts and reboots, and for as long as it is in use:
|
||||
its credential is renewed each time it checks in, and only expires after **180 days without being
|
||||
used** (ADR 0056). A screen that is switched off and stored for longer than that, or that is revoked,
|
||||
returns to its pairing code and is paired again the same way.
|
||||
|
||||
## What happens when things go wrong
|
||||
|
||||
| Event | What the screen does |
|
||||
| --- | --- |
|
||||
| Network drops for seconds | Nothing visible. The display keeps running; the realtime stream reconnects. |
|
||||
| Network or API down for longer | Keeps playing what it has cached, with a small corner note; recovers by itself when the server is back. No remount, no reload. |
|
||||
| API restarted or upgraded | Reconnects by itself. A new module version is picked up within five minutes and swapped in. |
|
||||
| Browser crashes or is restarted | systemd restarts it; the same profile is still paired. |
|
||||
| Machine reboots | Starts on boot and resumes. If the server is not up yet, the screen retries on its own (every few seconds, backing off to once a minute) until it is. |
|
||||
| Display revoked in the Dashboard | Stops showing content within seconds and returns to its pairing code. |
|
||||
| Display moved to another group or audience | Switches content within about one heartbeat (≈12 s), no reload. |
|
||||
|
||||
**Offline cold start is not supported yet.** A screen that is already showing content survives an
|
||||
outage. A screen that *boots* while the server is completely unreachable can load its cached page but
|
||||
cannot mount the module until the server answers, because the authorization it needs is never cached
|
||||
(ADR 0043, Signage ADR 0006). It shows a short message and retries on its own until the server is
|
||||
reachable — nobody needs to touch it.
|
||||
|
||||
## Checklist for a new kiosk
|
||||
|
||||
- [ ] One systemd unit, no desktop autostart entry, no second launcher.
|
||||
- [ ] `--user-data-dir` on persistent storage, not shared, not cleared on exit.
|
||||
- [ ] Automatic login of the kiosk user, and screen blanking/power saving disabled.
|
||||
- [ ] Paired and named; assigned in Signage.
|
||||
- [ ] Unplug the network for a minute: content keeps playing, then the note clears on reconnect.
|
||||
- [ ] Reboot: the screen comes back paired without anyone touching it.
|
||||
@@ -267,6 +267,8 @@ to follow — and the API never builds frontend source at run time.
|
||||
and its only allowed build script, and a second toolchain buys HMR that a proof does not need.
|
||||
- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus
|
||||
module routing is a design this pass should not settle by accident.
|
||||
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
|
||||
> now has host-owned URL routing; module routing remains a separate, deferred decision.
|
||||
- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs
|
||||
to the host, and adopting it here for a two-item selector would prejudge that design.
|
||||
- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
|
||||
|
||||
@@ -255,6 +255,10 @@ that is unremarkable, and it is measured work rather than hidden.
|
||||
|
||||
## Deferred
|
||||
|
||||
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** Client-side
|
||||
> routing and deep links to a selected module, deferred below, have landed; the rest of this list is
|
||||
> unchanged.
|
||||
|
||||
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
|
||||
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
|
||||
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
|
||||
|
||||
@@ -165,6 +165,10 @@ redirect target.
|
||||
A future client router may define an internal return-location contract, but it must be a separately
|
||||
validated, same-origin route decision. This pass does not create one speculatively.
|
||||
|
||||
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
|
||||
> now has a host-owned router. It adds no return-location contract: sign-in still happens in place at
|
||||
> the current URL, which is now a real page address.
|
||||
|
||||
### Cookie and origin policy does not broaden for the Dashboard
|
||||
|
||||
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-24
|
||||
- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md)
|
||||
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
|
||||
[0032](0032-native-module-realtime-transport.md),
|
||||
[0033](0033-display-kiosk-host-contract.md),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material
|
||||
|
||||
- Status: Proposed
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-07
|
||||
- Extends: [0049](0049-module-package-v1.md), in one respect only. ADR 0049 left exactly how licence
|
||||
and notice material is collected to the packager's implementation. That implementation, and the
|
||||
@@ -9,7 +9,9 @@
|
||||
further source: a reviewed supplement. Everything else in ADR 0049 is unchanged, as is
|
||||
`packageFormat`.
|
||||
- Related: [ADR 0048](0048-generic-image-and-installed-module-storage.md),
|
||||
[ADR 0051](0051-module-package-license-material.md)
|
||||
[ADR 0051](0051-module-package-license-material.md),
|
||||
[ADR 0055](0055-corresponding-source-artifacts.md) (decides the source-obligation policy deferred
|
||||
below)
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# 0055: Native redistributable packages carry corresponding-source artifacts
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-09
|
||||
- Extends: [0049](0049-module-package-v1.md). ADR 0049 withholds distributable-release status from a
|
||||
package until its licence handling is proven. This ADR adds what proving it means for a native
|
||||
package that redistributes copyleft code: a sibling corresponding-source artifact, and a
|
||||
fail-closed gate for public distribution. Module Package v1 is unchanged, and `packageFormat`
|
||||
stays **1**.
|
||||
- Decides: the item [ADR 0052](0052-reviewed-license-supplements.md) deferred, "the legal and
|
||||
compliance determination of source obligations for each redistributed binary, and how a
|
||||
determination is recorded", as a project policy. Reviewed supplements are unchanged in purpose
|
||||
and format.
|
||||
- Related: [ADR 0051](0051-module-package-license-material.md), whose rules for a module's own
|
||||
licence are unchanged.
|
||||
|
||||
## Context
|
||||
|
||||
A native Module Package (ADR 0049) copies native dependencies into its `node_modules` exactly as
|
||||
built. The first one, the packager's native proof with sharp, redistributes
|
||||
`@img/sharp-libvips-linux-x64`. That is one shared object with about 29 libraries built into it.
|
||||
Several are under the GNU LGPL, and cairo and some Rust crates are under the MPL.
|
||||
|
||||
ADR 0052 made the licence and notice side work. A reviewed supplement adds the licence, copyright,
|
||||
and notice files the package omits, with the exact upstream source each came from. But ADR 0052 was
|
||||
explicit that carrying material is not meeting obligations. The LGPL and the MPL also oblige whoever
|
||||
distributes the binary to make the corresponding source available. For a library linked into a
|
||||
larger work, that includes what a recipient needs to rebuild the library and use a modified version.
|
||||
Recording upstream URLs does not do that: URLs move, tags are rewritten, and a mirror is somebody
|
||||
else's promise.
|
||||
|
||||
Until this was decided, every native package containing such components was blocked from public
|
||||
distribution, and the blocker had no defined way to be lifted.
|
||||
|
||||
## Decision
|
||||
|
||||
### Native packages may contain copyleft native dependencies
|
||||
|
||||
A native Module Package may redistribute native dependencies under the LGPL, the MPL, or a similar
|
||||
licence with source obligations. It may be **distributed publicly** only when it is released
|
||||
together with a **corresponding-source artifact** that meets this ADR. A package whose artifact
|
||||
cannot be produced and verified is not approved for public distribution.
|
||||
|
||||
This is Ekklesia's **project compliance policy**: a conservative rule the project holds itself to.
|
||||
It is not legal advice, and it is not legal certification. Repository review of the artifact, its
|
||||
inputs, and its instructions is project and code review. It does not replace professional legal
|
||||
review, and nothing built from this ADR may claim that it does.
|
||||
|
||||
### Supplements and source artifacts do different jobs
|
||||
|
||||
- **A reviewed supplement (ADR 0052)** identifies and verifies the licence and notice material for a
|
||||
redistributed binary. It travels inside the package, in `THIRD_PARTY_NOTICES`.
|
||||
- **A corresponding-source artifact** carries the source, patches, build definitions, and rebuild
|
||||
and relink instructions for the covered native components. It travels beside the package, as a
|
||||
separate release artifact.
|
||||
|
||||
A supplement alone never satisfies the source-availability policy. A source artifact never replaces
|
||||
the supplement or `THIRD_PARTY_NOTICES`.
|
||||
|
||||
### What the artifact contains
|
||||
|
||||
For each covered native dependency the package redistributes, the artifact contains:
|
||||
|
||||
- the exact source the binary was built from, as bytes, not as references:
|
||||
- its build definitions;
|
||||
- the source archive of every library compiled into it;
|
||||
- every patch the build applied;
|
||||
- every source package a language package manager resolved for the build;
|
||||
- the source of any runtime library the toolchain linked into it;
|
||||
- the licence and copyright material: the reviewed supplement, and the files inside the sources;
|
||||
- **instructions** that are technically actionable. They name what the artifact corresponds to and
|
||||
its target, and say how to apply the patches and rebuild the covered library. They also say how a
|
||||
rebuilt or modified library replaces the redistributed one in the installed module, and which
|
||||
toolchain and configuration to use, or point to the included build definitions;
|
||||
- a deterministic, machine-readable manifest of every component and file, each with its digest,
|
||||
plus the human-readable provenance.
|
||||
|
||||
Components that are not part of the binary are left out, each with its reason recorded: build-only,
|
||||
test-only, or another platform's. The exception is a build-time input needed to rebuild the covered
|
||||
library, such as a source package a build script requires. That is included and marked as
|
||||
build-only. Host system libraries and general-purpose build tools are not redistributed, and the
|
||||
instructions name the versions they need.
|
||||
|
||||
### It is tied to exact inputs, and verified before it is written
|
||||
|
||||
- **Bound to the binary.** An artifact names each covered dependency by package name, version, and
|
||||
the content digest a supplement is matched by (ADR 0052). It names the module identity and target
|
||||
of the package it accompanies. An artifact for one version of a binary never satisfies another.
|
||||
- **From reviewed inputs only.** The inputs come from a checked-in, reviewed **source recipe** per
|
||||
covered dependency version. The recipe records each component's name, version, origin,
|
||||
relationship to the binary, and licence material, and each source file's and patch's size and
|
||||
SHA-256.
|
||||
- **Acquisition is separate from construction.** Obtaining the recorded bytes may use the network,
|
||||
in an explicit preparation step that stores them in a local cache keyed by content digest.
|
||||
Constructing the artifact is offline. It reads only the cache and checked-in files, verifies every
|
||||
byte against the recipe before writing anything, and never fetches or trusts a current upstream
|
||||
response.
|
||||
- **Deterministic.** The same inputs produce the same artifact, byte for byte, in any working
|
||||
directory: normalized relative paths, fixed ownership, modes, and timestamps, sorted entries, and
|
||||
no machine-specific path.
|
||||
|
||||
### Binary and source are linked by recorded digests
|
||||
|
||||
A public release of a native package with covered components consists of sibling artifacts:
|
||||
|
||||
- the Module Package archive and its SHA-256 (ADR 0049);
|
||||
- the corresponding-source archive and its SHA-256;
|
||||
- machine-readable **redistribution metadata** recording:
|
||||
- the package archive's name and SHA-256;
|
||||
- the module identity and target;
|
||||
- each covered dependency's name, version, licence, and content digest;
|
||||
- the supplement and recipe it was assembled from;
|
||||
- the source archive's name and SHA-256, and the SHA-256 of its manifest;
|
||||
- a **release notice**, displayed alongside the binary wherever it is offered. It says, in
|
||||
substance, that the release includes third-party software under licences including the GNU LGPL
|
||||
or the MPL, and that corresponding source and build and relink materials for the covered native
|
||||
dependencies are in the accompanying artifact, named by its exact file name and SHA-256.
|
||||
|
||||
A reviewer can therefore tell exactly which source artifact corresponds to which redistributed
|
||||
binary, from digests, without guessing from file names. The source archive is never placed inside
|
||||
the Module Package. `THIRD_PARTY_NOTICES` stays as it is, and never duplicates the source.
|
||||
|
||||
### Public distribution fails closed
|
||||
|
||||
Building and testing packages stays unrestricted: the packager produces native packages exactly as
|
||||
before, for development, CI, and private use. **Public redistribution approval** is a separate gate.
|
||||
For a package intended for public distribution, the gate requires that:
|
||||
|
||||
- the package's native dependency inventory is known;
|
||||
- every covered dependency has its reviewed supplement and source recipe;
|
||||
- the corresponding-source artifact is present;
|
||||
- every digest matches;
|
||||
- the artifact's manifest validates against the recipe and the archive's own contents;
|
||||
- the release notice names the exact artifact.
|
||||
|
||||
If any requirement fails, approval fails. Bundled (not copied) code under such a licence is not
|
||||
handled by this mechanism, so the gate refuses it rather than approving it.
|
||||
|
||||
### Retention, and no rewriting history
|
||||
|
||||
- Ekklesia keeps a release's corresponding-source artifact for as long as it distributes, or
|
||||
supports access to, the matching binary release. Nothing deletes one automatically while that
|
||||
binary remains available.
|
||||
- The bytes of a source artifact for an existing binary release are never replaced under the same
|
||||
identity.
|
||||
- A correction is a new release, or an explicitly versioned compliance artifact that the
|
||||
redistribution metadata names. History is never silently replaced.
|
||||
|
||||
### What this ADR does not do
|
||||
|
||||
- It does not change any module's own licence, or ADR 0051's rules for it. The module's own source
|
||||
obligations are met by the module's own repository, as before.
|
||||
- It does not make **runtime downloading** an acceptable workaround. Fetching a copyleft library at
|
||||
run time instead of redistributing it moves the obligation rather than meeting it, makes
|
||||
installations depend on a third party, and breaks ADR 0048's self-contained installed modules. It
|
||||
is not the preferred path.
|
||||
- It does not change Module Package v1, the verifier's acceptance of a package, or the content of
|
||||
existing supplements.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Native modules that need copyleft native libraries, starting with sharp's libvips, can be
|
||||
distributed publicly under a defined, checkable policy, instead of being blocked indefinitely.
|
||||
- Each covered dependency version needs a reviewed source recipe, as it needs a reviewed supplement.
|
||||
A dependency bump needs both before public distribution works again.
|
||||
- Releases carry an artifact many times larger than the package itself (about 200 MB for libvips
|
||||
1.3.4, against a 9 MB package), and Ekklesia commits to retaining it.
|
||||
- Constructing the artifact needs the verified source cache. Acquisition happens once per recipe,
|
||||
not on every build.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Treat supplements, source URLs, or a written offer as sufficient.** Rejected: URLs and offers
|
||||
depend on third parties and on time. This policy requires the source itself, retained with the
|
||||
release.
|
||||
- **Put the source inside the Module Package.** Rejected: installations would store hundreds of
|
||||
megabytes they never need, and the package format would change for a distribution concern.
|
||||
- **Download sources while constructing the artifact.** Rejected: a release would depend on mutable
|
||||
remote responses. Acquisition is explicit and verified, and construction is offline.
|
||||
- **Download native libraries at run time.** Rejected, as above.
|
||||
- **Per-dependency special cases in code.** Rejected for the same reason as in ADR 0052: recipes are
|
||||
reviewed data, and the mechanism knows no particular library.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Hosting and serving source artifacts for published module releases, and the release CI that runs
|
||||
the gate and keeps the verified source cache.
|
||||
- Corresponding source for bundled copyleft code, if a module ever needs it.
|
||||
- Recipes provided by module repositories, alongside the question ADR 0052 deferred for supplements.
|
||||
@@ -0,0 +1,76 @@
|
||||
# 0056: A device credential lasts while the device is in use
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-12
|
||||
- Supersedes: the "Session lifetime" section of [0036](0036-managed-device-identity-and-pairing.md).
|
||||
Everything else in ADR 0036 — the device as its own principal, pairing, revocation on every
|
||||
request, re-pairing as a new identity — is unchanged.
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0036 gave a device credential a fixed 180 days from pairing. The intent was that a screen on a
|
||||
ceiling should not need re-pairing on a short cycle, and that an abandoned screen should eventually
|
||||
stop being an identity.
|
||||
|
||||
A fixed horizon does the second and fails the first. Every screen in the building expires on the
|
||||
anniversary of its pairing whether or not it was in use the day before, and drops to a pairing code
|
||||
in front of the congregation until someone with Dashboard access walks to it. For screens paired
|
||||
together, that is every screen at once. Nothing about a working, unrevoked screen is less trustworthy
|
||||
on day 181 than on day 179; only the calendar changed.
|
||||
|
||||
## Decision
|
||||
|
||||
**The 180 days are an idle window, not a lifetime.** A device credential is accepted while the
|
||||
device was last seen less than 180 days ago (or, if it has never been seen, was paired less than 180
|
||||
days ago). `lastSeenAt` already moves forward on authenticated device requests (throttled to one
|
||||
write per 15 minutes), so the server-side window slides with use and needs no new column or
|
||||
migration.
|
||||
|
||||
**The browser's copy is renewed by the host, not rotated.** The display descriptor —
|
||||
`GET /api/v1/module-displays/<module>`, which every display requests when it starts, when it
|
||||
reconnects, and periodically while it runs — re-issues the device cookie that authenticated the
|
||||
request with a fresh 180-day `Max-Age` and the same attributes as at pairing (`HttpOnly`,
|
||||
`SameSite=Strict`, host-only, `Path=/`, `Secure` on https). The value is unchanged.
|
||||
|
||||
Renewal happens only for a request that already resolved to a live, unrevoked device. A revoked,
|
||||
expired, or unknown credential is anonymous, as before, and is never renewed; a person viewing a
|
||||
display never receives a device cookie.
|
||||
|
||||
**Revocation remains the boundary.** It is still checked on every request, and an administrator's
|
||||
revoke still takes effect immediately.
|
||||
|
||||
## Why not rotate
|
||||
|
||||
Rotation (issue a new value, invalidate the old) would bound how long any one stolen value works and
|
||||
could reveal a cloned credential. For an unattended screen it trades that for a new way to strand
|
||||
the display: a renewal response lost to a network drop, a power cut, or a browser that never
|
||||
persisted the new cookie leaves the screen holding a dead credential and showing a pairing code,
|
||||
which is the failure this decision exists to remove. A refresh-token scheme with overlap windows
|
||||
could avoid that, at the cost of real complexity in the one place mistakes are invisible until a
|
||||
wall goes dark. That is not justified while revocation is immediate and a device principal can only
|
||||
read the displays it was paired for.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A screen that is used at least once every 180 days stays paired until an administrator revokes
|
||||
it. An abandoned screen still stops being an identity 180 days after it was last seen.
|
||||
- Existing credentials need no migration. A screen paired under the fixed rule is renewed the next
|
||||
time it requests its descriptor, provided its browser still holds the cookie.
|
||||
- A stolen device credential stays usable for as long as anyone keeps using it, until it is revoked.
|
||||
Under ADR 0036 it was usable for at most 180 days from pairing. The exposure is bounded by what a
|
||||
device may do — the display permissions of the module it was paired for, never a person's access
|
||||
— and by revocation. Operators can see each device's last-seen time and revoke anything
|
||||
unexpected.
|
||||
- A credential is not bound to a browser or network. Like a human session cookie, whoever presents
|
||||
it is the device.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Keep the fixed 180 days.** Rejected: every in-use screen fails on a schedule, and screens paired
|
||||
together fail together.
|
||||
- **Never expire.** Rejected: an abandoned or forgotten screen would remain an identity forever, and
|
||||
its credential would never stop working without someone remembering to revoke it.
|
||||
- **Rotate on renewal.** Rejected for now; see above.
|
||||
- **Renew on every authenticated response.** Rejected: principal resolution happens deep in shared
|
||||
middleware that never writes responses. One endpoint every display already requests periodically
|
||||
is enough, and keeps the behaviour in one visible place.
|
||||
@@ -0,0 +1,237 @@
|
||||
# 0057: Dashboard URLs are real, host-owned routes
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-14
|
||||
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||
[0027](0027-dashboard-navigation-and-flat-modules.md),
|
||||
[0034](0034-host-owned-browser-authentication.md),
|
||||
[0044](0044-self-hosted-reference-deployment.md),
|
||||
[0045](0045-split-dashboard-and-api-origins.md)
|
||||
|
||||
## Context
|
||||
|
||||
The Dashboard had exactly one URL. `apps/api` served the page at `/` and nowhere else, both
|
||||
reference proxies forwarded only `/` to it, and the shell kept "which module is open" in React state.
|
||||
Opening a module never changed the address bar, so a refresh always returned to the first module,
|
||||
nothing could be bookmarked or shared, and the browser's Back button left the Dashboard entirely.
|
||||
ADR 0026 rejected a client router *for that pass* — "a selected-module state is sufficient" — and
|
||||
ADR 0027 deferred client routing and deep links.
|
||||
|
||||
The product contract now requires them. DESIGN.md §2.7 says the URL represents the current place in
|
||||
the product: a refresh returns to the same page, deep links work, Back and Forward behave
|
||||
predictably. §5 makes an **Overview** the Dashboard's landing page, and §12.6 says a disabled module's
|
||||
routes stop behaving as active routes. Everything else Stage 1 builds — the sidebar, Settings, the
|
||||
account area, platform Displays, a module's own pages in the sidebar, and returning to the workflow a
|
||||
Settings deep link came from — needs stable addresses to hang from, so this is the first slice.
|
||||
|
||||
A route that works in the shell but not on refresh is worse than no route, because it looks like it
|
||||
works. Refresh is a server and proxy concern before it is a browser one: the API had to serve the
|
||||
page at the new paths, and the proxies had to forward them.
|
||||
|
||||
## Decision
|
||||
|
||||
### One top-level URL space, shared by platform pages and module pages
|
||||
|
||||
```text
|
||||
/ the Overview
|
||||
/settings[/…] Settings
|
||||
/account[/…] the account area
|
||||
/<module-id> a module's page: /signage, /calendar, /people
|
||||
/<module-id>/<page>[/…] reserved for that module's own pages: /signage/timelines
|
||||
```
|
||||
|
||||
A module is a first-class top-level destination, addressed by its id, exactly as Settings and the
|
||||
Account area are. "Module" is an implementation concept — how the platform packages a product — and
|
||||
it does not appear in the addresses people see, bookmark, and share. A module id is lowercase
|
||||
kebab-case and Core-validated, which is what makes it usable as a path segment with no further
|
||||
encoding.
|
||||
|
||||
### Platform-owned top-level names are reserved, and Core enforces it
|
||||
|
||||
Sharing one namespace means a module must never be able to take a name the platform uses.
|
||||
`RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`) is the complete set the platform serves
|
||||
at the top level **today**, taken from what is actually registered rather than from guesses:
|
||||
|
||||
| Name | Owned by |
|
||||
| --- | --- |
|
||||
| `api` | the API: identity, principal, health, Core resources, module routes, module UI artifacts, display descriptors, realtime |
|
||||
| `assets` | the Dashboard's and display host's scripts |
|
||||
| `display` | the display host's pages and service worker |
|
||||
| `settings` | the Dashboard's Settings pages |
|
||||
| `account` | the Dashboard's account area |
|
||||
|
||||
Discovery refuses a module that declares one (`MODULE_ID_RESERVED`), so the check covers API
|
||||
startup, `pnpm modules:validate`, and Module Package verification — before a module is installed,
|
||||
not only when it is served. Exact names only: `settings-sync` or `displays` are ordinary ids.
|
||||
|
||||
The set is held to reality by tests in `apps/api` rather than by review: every top-level segment the
|
||||
composed API registers is a reserved name or a module page, the display host's and Dashboard
|
||||
script's path constants all start with a reserved name, and every Dashboard page the shell
|
||||
interprets is reserved. Adding a new top-level platform route therefore fails a test until its name
|
||||
is reserved. Reserving a name later is a **breaking change** for any installation with a module of
|
||||
that id, so the set grows only deliberately, and only for a real route.
|
||||
|
||||
The rule lives in Core's discovery rather than in the module manifest grammar
|
||||
(`@ekklesia/contracts`), because it is the Ekklesia host's URL space, not a property of a module: a
|
||||
standalone host (ADR 0021) has no Settings page to protect. A module built against the current
|
||||
public packages is unaffected unless it uses one of these five names.
|
||||
|
||||
### The server serves one page, unchanged, at every real Dashboard address — and nowhere else
|
||||
|
||||
`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at `/settings` and
|
||||
`/account` and everything below each (`DASHBOARD_PLATFORM_PAGES`, exported by the shell as
|
||||
`@ekklesia/shell/dashboard-routes`, so the server and the shell's router read one list), and at
|
||||
`/<module-id>` and everything below it for **each module this process discovered that declares a
|
||||
Dashboard UI**. Every answer is the same frozen bytes, the same strong `ETag`, and the same security
|
||||
headers as `/`: the path selects a page inside the shell, never a file on the server. Nothing from
|
||||
the request is composed into a lookup, no directory is exposed, and the script and source map remain
|
||||
reachable only at their own literal paths.
|
||||
|
||||
The module set is the boot snapshot's discovered inventory, fixed at startup like every other
|
||||
module-derived route, and it deliberately **includes disabled and blocked modules**. A bookmark to a
|
||||
module an administrator turned off still opens the Dashboard, which says the module is unavailable;
|
||||
that is more useful than an address that suddenly looks like it never existed. A module that is not
|
||||
installed, or that has no Dashboard UI (a display-only module, ADR 0033), has no page.
|
||||
|
||||
Everything else stays the sanitized `404`: an arbitrary root path (`/definitely-not-a-real-module`),
|
||||
a look-alike (`/settings-old`, `/signages`), another method, `/index.html`, and so on. The routes
|
||||
also refuse to compose if handed a module page that is not a valid module id, is a reserved name, or
|
||||
appears twice — discovery already guarantees all three, and the routes do not trust it.
|
||||
|
||||
Serving `/<module-id>` before sign-in means an anonymous request can learn whether a module with a
|
||||
Dashboard UI is installed, by its `200` or `404`. That is accepted: it is the same disclosure the
|
||||
display host already makes at `/display/<module-id>` (ADR 0033), a module id names a product rather
|
||||
than any data, and the alternative — a page at every module-shaped root path — is exactly the
|
||||
"arbitrary path becomes a Dashboard page" this decision rejects. The authenticated module inventory
|
||||
and every module route keep their authorization unchanged.
|
||||
|
||||
### The proxies forward module-shaped names and let the API decide
|
||||
|
||||
A proxy cannot know which modules an installation has, and a proxy configuration listing them would
|
||||
have to be edited on every install. Both reference Caddyfiles therefore forward every top-level path
|
||||
whose first segment has a module id's shape, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$`, and the API
|
||||
answers it with the page or its sanitized `404`. Anything not shaped like one — `/favicon.ico`,
|
||||
`/.well-known/…`, an uppercase name — is still refused by the proxy without reaching the API. In the
|
||||
split topology (ADR 0045) the Dashboard hostname forwards module-shaped names but still refuses
|
||||
`/api`, `/display`, and `/assets`, and the API hostname still serves no Dashboard page. The
|
||||
development server falls back to the page for any path with no file.
|
||||
|
||||
### The shell's router is host-owned, small, and deliberately not a library
|
||||
|
||||
The address bar is the source of truth for which page is open. The shell reads it through a
|
||||
four-method `HostHistory` (`pathname`, `push`, `replace`, `subscribe`); the browser implementation is
|
||||
built in the entry point from `window` and handed down, so the entry point remains the only file in
|
||||
the package that touches a browser global, and every routing behaviour is tested against an in-memory
|
||||
history whose entries a test can inspect. No state object is ever stored in a history entry — the path
|
||||
alone is meaningful — so nothing put into history can be read back as authority.
|
||||
|
||||
`react-router-dom` is not adopted. Modules are refused it because routing belongs to the host
|
||||
(ADR 0022, `pnpm boundaries`); the shell's own pages are a handful of host screens plus one mounted
|
||||
module; and adopting a library now would prejudge how a module's own pages are addressed, which is
|
||||
a separate decision (see **Deferred**).
|
||||
|
||||
### What each path shows
|
||||
|
||||
- `/` is the **Overview**. The shell no longer opens the first module for the person: the Overview is
|
||||
the landing page. Until modules can contribute cards to it, it says plainly that it has nothing to
|
||||
show yet.
|
||||
- `/<module-id>` mounts the module whose navigation entry has that id, through the unchanged generic
|
||||
loader and the unchanged keyed frame, so a stale load still cannot reach a newer page. The server's
|
||||
navigation is the only authority on what is mountable. An id it does not list — a disabled or
|
||||
blocked module whose address the server still serves — shows *This page isn't available* and loads
|
||||
nothing; the page never echoes the address back. This is what makes a disabled module's routes stop
|
||||
behaving as active routes, with no new server state.
|
||||
- `/settings…` and `/account…` are the platform's own pages and are never read as module pages. They
|
||||
have no content yet, so they show *Page not found* for now.
|
||||
- Everything below `/<module-id>/` belongs to that module's own pages. It is served by the API today
|
||||
and shows *Page not found* in the shell until the module page contract exists. The shell never
|
||||
guesses, and never shows a module's first page for an address the module never defined.
|
||||
- One trailing slash is tolerated and corrected to the canonical spelling in place (`replace`, no new
|
||||
entry), so what is bookmarked next is the one canonical address.
|
||||
|
||||
### Navigation is real links
|
||||
|
||||
Every navigation item is an `<a href>` with its canonical address and `aria-current="page"` when it
|
||||
is open, marked by weight as well as colour. An ordinary click opens the page in place and adds one
|
||||
history entry; a click with a modifier key or the middle button is left to the browser, so a page can
|
||||
be opened in a new tab. Clicking the open page's own link adds no entry. The browser's Back and Forward
|
||||
walk exactly the pages that were open.
|
||||
|
||||
### Page changes behave like page loads for assistive technology
|
||||
|
||||
After an in-place page change the shell moves focus to the page's `<main>` (named after the page) as
|
||||
a full load would have; the first page shown after a refresh or sign-in is left alone. The entry point
|
||||
names the browser tab after the open page ("Overview", the module's display name, or the error page's
|
||||
title, and "Sign in" while signed out) — never "Ekklesia", in keeping with ADR 0046. The static page's
|
||||
own `<title>` is unchanged here; it belongs to the branding work.
|
||||
|
||||
### Sign-in is unchanged, and still has no return URL
|
||||
|
||||
ADR 0034 holds exactly. The sign-in form still replaces the application in place at the current URL,
|
||||
so a deep link opened while signed out lands on its own page once the person signs in — with no
|
||||
`return`, `next`, or callback value, no redirect, and no script-driven navigation off the
|
||||
Dashboard's own paths. The shell only ever moves between its own canonical paths. Emailed
|
||||
verification and reset links still land on `/` with a query (ADR 0038).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Module pages under a prefix (`/modules/<id>`).** Rejected: it exposes an implementation concept
|
||||
in every address people see, and makes a module a second-class destination beside Settings. The
|
||||
collision it avoided is handled instead by reserving the platform's names in Core.
|
||||
- **Hash routing (`/#/signage`).** Rejected: every address is one page to the server, the proxy, and
|
||||
any analytics; fragments never reach the server, so no server-side decision about a path is
|
||||
possible; and DESIGN.md asks for real URLs.
|
||||
- **Serving the page at any module-shaped root path.** Rejected: `/definitely-not-a-real-module`
|
||||
would become a Dashboard page, and the server would answer `200` for every mistyped address. It
|
||||
would remove the installed-module disclosure described above, at the cost of the server no longer
|
||||
meaning anything by a `200`.
|
||||
- **Serving only enabled, running modules' pages.** Rejected: a bookmark to a module an
|
||||
administrator disabled would become a bare `404`, indistinguishable from a typo, instead of the
|
||||
Dashboard explaining that the module is unavailable.
|
||||
- **Listing modules in the proxy configuration.** Rejected: every install would need a proxy edit.
|
||||
- **Reserving more names now** (`login`, `help`, `overview`, …) "just in case". Rejected: a reserved
|
||||
name refuses a real module, so a name is reserved when the platform gets a route there and not
|
||||
before.
|
||||
- **Putting the reserved names in the manifest grammar.** Rejected for now: it is the Ekklesia host's
|
||||
URL space, not a module property, and would need a public-contract release for no extra safety,
|
||||
since discovery already refuses the module before it can be installed.
|
||||
- **`react-router-dom` or another routing library.** Rejected for now; see above.
|
||||
- **Keeping "open the first module" at `/`.** Rejected: DESIGN.md makes the Overview the landing page,
|
||||
and an address that silently means "whatever happens to be first" is not a stable address.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A refresh, a bookmark, and a shared link land on the page that was open; Back and Forward walk the
|
||||
Dashboard's own pages. Proven at three levels: the shell against an in-memory history; `apps/api`
|
||||
serving the page at platform pages and installed modules' pages and the sanitized `404` everywhere
|
||||
else; and the deploy smoke test, through the real proxy, checking platform deep links, unknown
|
||||
names, a module's address staying served while the module is disabled and disappearing and
|
||||
returning as it is removed and reinstalled,
|
||||
and reloading a module's address in a real browser and requiring the same module to mount again.
|
||||
- **The Dashboard opens on the Overview, not on the first module.** A person who used to land in
|
||||
Signage now chooses it once from the navigation, and can bookmark `/signage`.
|
||||
- **A module may not be called `account`, `api`, `assets`, `display`, or `settings`.** No known module
|
||||
is; one that was would now fail discovery with `MODULE_ID_RESERVED` and must be renamed.
|
||||
- **Operators with their own proxy must forward module-shaped top-level paths** (the matcher above),
|
||||
or a refresh on any page other than `/` is the proxy's `404`. The deployment guide says so.
|
||||
- `@ekklesia/shell` gains a `./dashboard-routes` subpath, and `apps/api` reads it. The surface remains
|
||||
private; this is the same relationship `./dashboard-assets` already has.
|
||||
- The navigation landmark is labelled "Dashboard" rather than "Modules", because it now contains the
|
||||
Overview as well as modules.
|
||||
|
||||
## Deferred
|
||||
|
||||
Each of these needs its own decision. This ADR reserves the addresses for them and decides none of
|
||||
them:
|
||||
|
||||
- **A module's own pages** — how a module declares the pages that appear under it in the sidebar
|
||||
(`/signage/playback`, `/signage/timelines`, `/signage/displays`), how it learns and changes its
|
||||
current sub-path through the module UI SDK, and what an address below `/<module-id>/` means. The
|
||||
server already serves the page there and the proxies already forward it, so that contract is a
|
||||
manifest, SDK, and shell change with no further routing or deployment change. It is its own ADR.
|
||||
- **Return to origin** — how a module or a Settings page links into another part of the Dashboard and
|
||||
comes back to the exact workflow that sent the person there (DESIGN.md §2.7, §4.8). It must remain
|
||||
a validated, same-origin route decision, as ADR 0034 requires, and is designed with the module page
|
||||
contract rather than here.
|
||||
- The Settings and Account pages themselves, the sidebar redesign, the mobile drawer, Overview cards,
|
||||
breadcrumbs, and the installation's name in the browser tab.
|
||||
@@ -0,0 +1,192 @@
|
||||
# 0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-14
|
||||
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||
[0027](0027-dashboard-navigation-and-flat-modules.md),
|
||||
[0039](0039-shared-design-tokens-and-elements.md),
|
||||
[0057](0057-dashboard-urls-are-host-owned-routes.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0057 gave every Dashboard page a real address, but the shell around those pages was still the
|
||||
first proof's layout: a top header holding a hard-coded product name and a Sign out button, a fixed
|
||||
`15rem` column of text buttons, and the transitional device panel above the navigation. It had no
|
||||
icons, could not collapse, had no account area, and on a phone it was the same two columns squeezed
|
||||
until the page was unusable.
|
||||
|
||||
DESIGN.md §4 fixes what the shell is: a collapsible left sidebar — icons and labels expanded, icons
|
||||
only collapsed, with accessible names — church identity at the top, the account area anchored at the
|
||||
bottom, no persistent top bar, and on phones a drawer with the **same** hierarchy (locked decisions
|
||||
4–8). It left one thing open that this work cannot proceed without: **which icon library**
|
||||
(§27, "Final free icon library selection"). The maintainer has decided it: Lucide.
|
||||
|
||||
## Decision
|
||||
|
||||
### Lucide is Ekklesia's icon vocabulary
|
||||
|
||||
Lucide (`lucide-react`, ISC-licensed, no runtime dependencies) is the one icon set for first-party
|
||||
Ekklesia UI: the shell, `@ekklesia/ui` if it ever draws an icon, and first-party modules. Where
|
||||
Lucide has an appropriate icon, nobody draws their own; a meaning that Lucide genuinely cannot
|
||||
express is the only reason to design one, and that is a decision to record, not a shortcut.
|
||||
|
||||
Icons are **decorative**. Every control that shows one also has a text name — visible, or visually
|
||||
hidden when the sidebar is collapsed — and every icon is `aria-hidden`. An icon never carries
|
||||
meaning alone, and its stroke is `currentColor`, so it takes its colour from the design tokens
|
||||
exactly as text does (ADR 0039). Icons are imported by name, so a bundle contains exactly the icons
|
||||
it uses.
|
||||
|
||||
The shell imports every icon it uses in one file (`surfaces/shell/src/browser/shell-icons.tsx`),
|
||||
keyed by meaning — `overview`, `module`, `account`, `signOut`, … — so the vocabulary is reviewable
|
||||
and an icon is chosen once per meaning rather than per call site. `lucide-react` is a dependency of
|
||||
`@ekklesia/shell` only, pinned exactly (`1.48.0`); no public `@ekklesia/*` package gains it here.
|
||||
A module that needs icons in its own UI uses `lucide-react` itself, which `pnpm boundaries` already
|
||||
permits (module `.svg` presentation assets remain rejected).
|
||||
|
||||
### Modules have no icon of their own yet
|
||||
|
||||
Every module in the navigation is drawn with **one generic module icon** (Lucide `Blocks`). There is
|
||||
no table mapping known module ids to icons: that would be a second, host-owned answer to a question
|
||||
the module owns, and every new module would wait on a shell release to look right. A module will
|
||||
declare its own icon — by Lucide name — through the module navigation contract that also brings its
|
||||
sub-pages (manifest v9). Nothing in the manifest changes here.
|
||||
|
||||
### The shell's layout
|
||||
|
||||
On a wide screen (at or above `48em`, which is 768px at the default text size and follows a
|
||||
person's own text-size setting), the Dashboard is a sidebar and the page:
|
||||
|
||||
```text
|
||||
┌────────────┬──────────────────────────────┐
|
||||
│ identity ◂│ │
|
||||
│ Overview │ │
|
||||
│ GROUP │ the page │
|
||||
│ Module │ │
|
||||
│ Module │ │
|
||||
│ (devices) │ │
|
||||
│────────────│ │
|
||||
│ Your account │
|
||||
└────────────┴──────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Top:** the identity slot and the collapse toggle. The slot is a neutral placeholder (a plain mark
|
||||
and the product name) and deliberately takes **no inputs**: the branding work (issue #101) owns
|
||||
what goes there and where it comes from, and replaces this one component rather than undoing a
|
||||
configuration model invented early. It is not a heading; each page's own title is.
|
||||
- **Middle:** the navigation, scrolling on its own — the Overview first, then the server's groups and
|
||||
modules in the server's order (ADR 0027). The shell sorts and filters nothing; a disabled module is
|
||||
absent because the navigation endpoint does not list it (ADR 0028), and its address still shows
|
||||
*This page isn't available* (ADR 0057). Below it, while expanded, the transitional device panel
|
||||
(ADR 0036) until Settings → Displays replaces it.
|
||||
- **Bottom:** the account control, a product-owned menu button (below).
|
||||
|
||||
**Collapsed**, the sidebar is icons only (`4.5rem` rather than `16rem`). Each item keeps its text in
|
||||
the DOM, visually hidden, as its accessible name, and shows a tooltip on hover **and on keyboard
|
||||
focus**. The tooltip is a visual duplicate of that name, so it is `aria-hidden` and nothing is
|
||||
announced twice; it meets WCAG 2.2 §1.4.13 — dismissible with Escape without moving focus, hoverable,
|
||||
and persistent until focus or the pointer leaves — and is positioned beside the item so a scrolling
|
||||
sidebar cannot clip it. Group labels stay in the DOM for assistive technology, with a divider
|
||||
standing in for them visually. The toggle states what it does ("Collapse navigation" / "Expand
|
||||
navigation") and carries `aria-expanded` and `aria-controls`.
|
||||
|
||||
There is **no application top bar**: the page begins at the top of its column.
|
||||
|
||||
### Expanded or collapsed is a preference of this browser
|
||||
|
||||
The mode is remembered in this browser's `localStorage` under `ekklesia.dashboard.sidebar` (exactly
|
||||
`expanded` or `collapsed`; anything else is ignored), so it survives a reload. It is a preference of
|
||||
the device rather than the person: a narrow laptop and a wide desktop reasonably want different
|
||||
answers, and DESIGN.md's synced personal preferences belong to the account area, which does not exist
|
||||
yet. Storage can be missing, blocked, or throw; then the mode is remembered for the page only and
|
||||
nothing breaks.
|
||||
|
||||
The shell reads and writes it through a two-method `SidebarPreference` interface built by the entry
|
||||
point, so a synced or per-person store replaces the implementation without touching a component. The
|
||||
entry point likewise builds the browser's media query (`HostViewport`) and history (ADR 0057), and
|
||||
remains the only file in the package that touches `window`.
|
||||
|
||||
### The account area
|
||||
|
||||
The signed-in person's control at the bottom of the sidebar opens a product-owned menu following the
|
||||
WAI-ARIA menu button pattern: `aria-haspopup="menu"` and `aria-expanded` on the trigger; opening moves
|
||||
focus to the first item (ArrowUp opens on the last); arrow keys, Home, and End move between items;
|
||||
Escape closes it and returns focus to the trigger; Tab or a press outside closes it and leaves focus
|
||||
where it went. It contains **only actions that work today** — Sign out. Profile, personal preferences,
|
||||
appearance, and notifications belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages;
|
||||
until then there is no dead link to the reserved `/account`.
|
||||
|
||||
The shell knows the person only as an opaque user id (`/api/v1/principal`, ADR 0034), which is
|
||||
never displayed, so the control reads "Your account" with a generic avatar rather than a name or
|
||||
email. Showing the person's name needs a deliberate principal or profile projection, which this
|
||||
decision does not add.
|
||||
|
||||
### Phones: the same navigation in a drawer
|
||||
|
||||
Below the breakpoint the sidebar is not rendered. A single menu button ("Open navigation", with
|
||||
`aria-expanded` and `aria-controls`) sits above the page — the one control there, not a bar — and opens
|
||||
a drawer containing the **same** identity slot, navigation, current page, device panel, and account
|
||||
area, always with labels and with large-control touch targets (48px). The drawer is a real modal:
|
||||
|
||||
- `role="dialog"`, `aria-modal="true"`, named "Navigation";
|
||||
- the page behind it is `inert`, and Tab and Shift+Tab are also kept inside it, for a browser that
|
||||
does not yet honour `inert`;
|
||||
- opening moves focus to its close button; Escape, the close button, and the backdrop dismiss it and
|
||||
return focus to the menu button;
|
||||
- choosing a destination closes it and moves focus to the new page (ADR 0057), including when the
|
||||
destination is the page already open;
|
||||
- Back and Forward close it and still work.
|
||||
|
||||
### Changing layout never disturbs the page
|
||||
|
||||
The page's `<main>` keeps one position in the tree in both layouts, so rotating a tablet or resizing
|
||||
a window never remounts the open module. The route comes from the URL and the desktop mode from the
|
||||
preference, so neither is touched by a layout change. Only an open drawer is dropped, and focus that
|
||||
was inside navigation that has just gone away lands on the page rather than on the document.
|
||||
|
||||
### Shell-local, not yet `@ekklesia/ui`
|
||||
|
||||
The tooltip, the account menu, and the drawer live in the shell. ADR 0039 adds an element to
|
||||
`@ekklesia/ui` once two independent consumers need it, and today the shell is the only one; adding
|
||||
them to the public package would put unproven API in front of every module. Each is written as a
|
||||
self-contained, generic piece so it can be promoted unchanged when a module needs one. The shell's
|
||||
own stylesheet (hover, `:focus-visible`, reduced motion) is a token-only, `ek-shell-`-prefixed string
|
||||
installed into the shell's document, beside `@ekklesia/ui`'s, and cannot reach a module's isolated
|
||||
root (ADR 0040).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Another icon library** (Heroicons, Phosphor, Tabler, Material Symbols). Rejected by the
|
||||
maintainer's decision; Lucide's breadth, consistent stroke style, ISC licence, and per-icon imports
|
||||
fit a calm interface that many modules will extend.
|
||||
- **Ekklesia's own SVG set.** Rejected: an icon set is a long-term design and maintenance commitment
|
||||
that buys nothing a mature open set does not already have.
|
||||
- **A host-owned map of module ids to icons.** Rejected: the module owns its identity, and a map makes
|
||||
every new module wait on a shell release. The generic icon is honest until modules declare one.
|
||||
- **A module-manifest `icon` field now.** Deferred to the manifest-v9 navigation contract, so modules
|
||||
change manifest version once for icons, sub-pages, and the rest of that contract together.
|
||||
- **Persisting the mode on the server, per person.** Deferred to the account preferences work; the
|
||||
interface is shaped so that store can replace this one.
|
||||
- **CSS-only responsive switching.** Rejected: the drawer must be a modal with focus management, which
|
||||
needs the layout in script, and a media query adapter keeps it testable.
|
||||
- **A top bar holding the brand and the menu button on phones.** Rejected: DESIGN.md has no persistent
|
||||
top bar; one button is all the phone layout needs above the page.
|
||||
- **Native `<dialog>` for the drawer.** Not chosen for now: its focus return and inertness differ
|
||||
across the browsers a church's volunteers use, and the shell's own handling is explicit and tested.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The Dashboard has the shell DESIGN.md describes on desktop, tablet, and phone, proven in unit tests
|
||||
against in-memory history, viewport, and preference, and in the deploy smoke test in a real browser:
|
||||
at desktop size (the account menu's Sign out) and at phone size (the drawer's focus, Escape, and
|
||||
choosing a module), and back again.
|
||||
- Sign out moved from the header into the account menu.
|
||||
- The hard-coded product name now lives in one placeholder component, waiting for branding; there is
|
||||
no other person-facing "Ekklesia" in the authenticated shell.
|
||||
- `@ekklesia/shell` depends on `lucide-react`. No public package changed.
|
||||
|
||||
## Deferred
|
||||
|
||||
Church branding in the identity slot (issue #101); module-declared icons and module sub-pages in the
|
||||
sidebar (manifest v9); group icons and the navigation editor; the account pages and synced personal
|
||||
preferences, including a per-person sidebar mode; showing the person's name or picture; Settings in the
|
||||
navigation; promoting the tooltip, menu, or drawer to `@ekklesia/ui`.
|
||||
@@ -0,0 +1,196 @@
|
||||
# 0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-15
|
||||
- Extends: [0034](0034-host-owned-browser-authentication.md),
|
||||
[0039](0039-shared-design-tokens-and-elements.md),
|
||||
[0040](0040-module-stylesheets-and-artifact-isolation.md),
|
||||
[0057](0057-dashboard-urls-are-host-owned-routes.md),
|
||||
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md)
|
||||
|
||||
## Context
|
||||
|
||||
Every Stage 1 surface still to come — Settings, Displays, module management, Signage as the reference
|
||||
module — needs the same few interactions DESIGN.md fixes once for the whole product: a product-owned
|
||||
confirmation for meaningful actions, with typed confirmation for the serious ones (§8); top-center
|
||||
toasts (§7.4); errors that say what failed and keep technical detail out of the way (§7.3); skeleton
|
||||
loading that keeps context visible (§7.1); Back plus a page title for nested pages, returning to the
|
||||
workflow that sent the person there (§2.7, §4.8); and no `window.alert`, `confirm`, or `prompt`
|
||||
anywhere (§8.1).
|
||||
|
||||
`@ekklesia/ui` (ADR 0039) had the presentation of some of these and deliberately not the behaviour.
|
||||
ADR 0039 deferred "a focus-trap utility"; the package's `Dialog` said a focus trap belonged "in one
|
||||
place, tested, not sprinkled through call sites"; its `Toast` said there was "deliberately no toast
|
||||
manager", because a package-level singleton would be shared mutable state across every isolated
|
||||
artifact on a page (ADR 0040); and ADR 0057 deferred return-to-origin. The only guard against native
|
||||
dialogs was a regular expression in one shell test.
|
||||
|
||||
Each of these is now needed by more than one consumer, which is ADR 0039's bar for adding to the
|
||||
package.
|
||||
|
||||
## Decision
|
||||
|
||||
### Placement
|
||||
|
||||
The primitives with more than one consumer go in `@ekklesia/ui`; what depends on the Dashboard's own
|
||||
routes stays in the shell.
|
||||
|
||||
| Where | What |
|
||||
| --- | --- |
|
||||
| `@ekklesia/ui` | `ConfirmationDialog`; `ToastProvider` and `useToasts`; `ErrorState` (extended); `Skeleton` and `SkeletonGroup`; `PageHeader` (extended with `back`); `Dialog` (now with a focus trap) |
|
||||
| `@ekklesia/shell` | `parseReturnTarget` and `dashboardBack`: which addresses are safe to go back to |
|
||||
| Root `biome.json` and `tests/native-dialogs.test.ts` | the native-dialog guard |
|
||||
|
||||
The public additions are components and their prop types only. The focus trap, the duration rule,
|
||||
the phrase matcher, and the detail sanitizer are internal: tested, and used by the components, but
|
||||
not promised to callers. `@ekklesia/ui` gains no dependency — its few glyphs (`←`, `✕`, the status
|
||||
symbols) are text, as its status symbols already were, so a module bundle does not inherit an icon
|
||||
library's licence material through it.
|
||||
|
||||
### One focus trap, used by every modal surface
|
||||
|
||||
`useFocusTrap` remembers what had focus, moves focus into the surface, wraps Tab and Shift+Tab at its
|
||||
edges, and returns focus to the remembered control when the surface closes. It reads the focused
|
||||
element from the surface's **own root node**, so it works inside a module's shadow root, where the
|
||||
document would only report the shadow host (ADR 0040). `Dialog` uses it, so every dialog — including
|
||||
the shell's existing device-revocation dialog — now traps and restores focus.
|
||||
|
||||
`Dialog` stays rendered in place, never portalled out of an artifact's isolated root. Its scrim
|
||||
covers the viewport and takes every pointer press, so nothing behind it can be activated, and does
|
||||
not dismiss the dialog. Escape is now handled on the dialog element and stopped there, rather than on
|
||||
the whole document, so a drawer or menu the dialog was opened from no longer closes with it. A caller
|
||||
can make Escape inert while something is in progress (`dismissible={false}`), and give the dialog an
|
||||
accessible description.
|
||||
|
||||
### Confirmation: three severities, chosen by the caller
|
||||
|
||||
`ConfirmationDialog` is one element for a simple confirmation, a destructive one, and a destructive
|
||||
one that also requires a typed phrase. The consuming feature chooses; the element never upgrades or
|
||||
downgrades severity.
|
||||
|
||||
- It is a `Dialog`: named by its title, described by its description, focus trapped and restored.
|
||||
- **Destructive** is marked by a symbol in the title and on the action, a heavier frame, and the
|
||||
danger button — never by colour alone — and focus starts on **Cancel**, so a stray Enter is safe.
|
||||
- **Typed confirmation** is `requiredPhrase`: `DELETE`, or a resource's own name. Confirm is disabled
|
||||
until the typed value matches exactly and case-sensitively, forgiving only surrounding whitespace and
|
||||
Unicode composition (NFC), which phone keyboards introduce unasked. The field is labelled with the
|
||||
phrase, has autocapitalize, autocorrect, and spellcheck off, says politely whether it matches, and
|
||||
submits on Enter once it does. The typed value is forgotten whenever the dialog closes.
|
||||
- **Busy** disables both actions, makes Escape inert, and marks the form `aria-busy`, so an action in
|
||||
flight cannot be abandoned half-done; the caller closes the dialog when it finishes.
|
||||
|
||||
### Toasts: a system per React tree, never a package singleton
|
||||
|
||||
`ToastProvider` owns one tree's toasts, timers, and top-center region; `useToasts()` returns
|
||||
`show(options) → id` and `dismiss(id)`. ADR 0040's isolation is why there is no package-level store:
|
||||
each module artifact and the shell render their own tree, and a store shared through the package
|
||||
would be mutable state reaching across all of them.
|
||||
|
||||
- **Severities:** success, info, warning, and error (`danger`), each with its symbol.
|
||||
- **Duration:** never under about three seconds (DESIGN.md §7.4), longer for longer text and for
|
||||
warnings, up to a ceiling. An error stays until dismissed unless its caller gives it a duration — a
|
||||
routine validation error, say.
|
||||
- **Calm under load:** at most three routine toasts at once, newest on top; an error is listed first
|
||||
and never displaced by a routine toast; an identical toast already showing is refreshed rather than
|
||||
stacked.
|
||||
- **Accessible:** the region's polite and assertive live regions exist before any toast arrives, so
|
||||
additions are announced — routine toasts politely, errors assertively — and no toast is a nested
|
||||
live region of its own. Timers pause while the pointer is over the region or focus is in it (WCAG
|
||||
2.2 §2.2.1). Every toast has a dismiss control named for it; Escape dismisses the focused toast; when
|
||||
the last one goes, focus returns to where it was.
|
||||
- **Clean:** every timer is cleared when its toast goes and when the provider unmounts.
|
||||
|
||||
The shell does not mount a provider yet, and nothing is wired to toasts merely to show them. A module
|
||||
mounts its own provider today. One host-owned region that modules post into would need a module UI
|
||||
SDK contract, and is deferred.
|
||||
|
||||
### Errors: plain message first, technical detail on request
|
||||
|
||||
`ErrorState` keeps its existing props and adds `actions` (recovery and remedies — a caller that knows
|
||||
the viewer can administer the problem passes those remedies too; the element has no notion of roles),
|
||||
`details` (plain text), `reference` (a diagnostic id), and `announce` (`role="alert"` by default, off
|
||||
for an error that is part of a page's initial content). Details and the reference sit in a closed
|
||||
`<details>` disclosure, so they never dominate. `details` is a string, chosen deliberately: never an
|
||||
exception, so a stack cannot arrive by accident. It is rendered as text, so markup in it is shown and
|
||||
never interpreted, with control characters removed and its length bounded (2,000 characters).
|
||||
|
||||
### Skeletons: shape without pretence
|
||||
|
||||
`Skeleton` (text line, block, circle) is decorative and hidden from assistive technology.
|
||||
`SkeletonGroup` is the region it stands in for: `role="status"`, `aria-busy`, and one label announced
|
||||
once ("Loading displays") rather than every placeholder. Placeholders pulse gently and stop for a
|
||||
person whose system asks for reduced motion; there is no percentage, because there is no progress to
|
||||
report. When the personal reduced-motion preference arrives, it overrides the same single rule.
|
||||
|
||||
### Back: a validated origin, or the page's own parent
|
||||
|
||||
`PageHeader` takes an optional `back: { label, href?, onBack }` and renders it above the page's one
|
||||
`h1`: a real link when it has an address (so it can be opened in a new tab), a button otherwise.
|
||||
Breadcrumbs are not part of it (DESIGN.md §4.8). The element knows nothing about routes; the host
|
||||
decides where Back goes.
|
||||
|
||||
In the shell, `dashboardBack(history, { returnTarget, fallback, label })` decides. A return target is
|
||||
input from whoever built the link, so it is hostile until proven otherwise, and ADR 0034 requires any
|
||||
return location to be a validated, same-origin route decision. `parseReturnTarget` accepts only a
|
||||
string of at most 2 KiB that begins with exactly one `/`, contains no whitespace, control character,
|
||||
backslash, percent-encoded slash, backslash, or dot, and no dot-segment, query, or fragment, and names
|
||||
a page the Dashboard actually has. Its answer is a destination, never the raw string, so Back can only
|
||||
go to the canonical address of a real page. Without a valid target, Back goes to the page's declared
|
||||
parent. It never calls the browser's `history.back()`: after a bookmark or a link from outside, the
|
||||
previous entry is not the Dashboard's.
|
||||
|
||||
How a link *carries* a return target — a query parameter, or something the module navigation contract
|
||||
defines — is deliberately not decided here (ADR 0057); whatever carries it hands it to
|
||||
`parseReturnTarget` first.
|
||||
|
||||
### No native dialogs, enforced repository-wide
|
||||
|
||||
Biome's scope-aware `suspicious/noAlert` is an **error** in the root `biome.json`, which every package,
|
||||
surface, and template in this repository lints against. It reports `alert(`, `confirm(`, and
|
||||
`prompt(`, called bare, through `window` or `globalThis`, or by computed member, and leaves alone a
|
||||
local function that shares the name, a method such as `dialog.confirm()`, a property such as
|
||||
`onConfirm`, a string, and a comment. `tests/native-dialogs.test.ts` proves both directions against
|
||||
fixtures with the repository's own configuration, and scans browser source for the two forms the rule
|
||||
cannot see — a call through `self`, `top`, `parent`, or `frames`, and borrowing the function without
|
||||
calling it. The shell's earlier regular-expression test is superseded and removed.
|
||||
|
||||
A module repository has its own lint configuration and is outside this repository's reach. It should
|
||||
enable the same rule; the module development guide says so.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **A third-party toast or dialog library.** Rejected: the behaviour is small, it must work inside
|
||||
isolated roots without portals, and a framework would bring its own styling and a dependency into
|
||||
every module bundle.
|
||||
- **A package-level toast singleton (`toast("Saved")` from anywhere).** Rejected for ADR 0040's
|
||||
reason: shared mutable state across every artifact on the page.
|
||||
- **Native `<dialog>` with `showModal()`.** Not chosen for now: it promotes the dialog to the top
|
||||
layer, outside an artifact's isolated root, and its focus return and inertness differ across the
|
||||
browsers churches use.
|
||||
- **`history.back()` as Back's fallback.** Rejected: it can leave the Dashboard.
|
||||
- **Accepting any same-origin path as a return target.** Rejected: an address the Dashboard has no
|
||||
page for is not a workflow to return to, and a narrow grammar is easier to prove safe.
|
||||
- **Typed confirmation that ignores case.** Rejected: typing `DELETE` is meant to be deliberate.
|
||||
- **Enforcing native dialogs at module build time** (`@ekklesia/module-ui-build`). Not chosen: the build
|
||||
sees a bundle without scope information, so it would either miss shadowed names or report code in
|
||||
dependencies; a scope-aware lint rule in source is the accurate tool.
|
||||
- **Exporting the focus trap and helpers.** Deferred: they are tested internals until a caller needs
|
||||
them outside these components.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Later Stage 1 pages compose these instead of inventing their own, and modules get the same ones.
|
||||
- `Dialog`'s Escape is handled on the dialog element rather than on the document. A caller that relied
|
||||
on Escape reaching the document while focus was elsewhere sees a change; with focus trapped inside
|
||||
the dialog, that situation no longer arises. The existing test was updated accordingly.
|
||||
- `@ekklesia/ui` grows by two components, a provider and its hook, and three extended props. It remains
|
||||
dependency-free beyond `@ekklesia/design-tokens`.
|
||||
- `pnpm lint` now fails on any native dialog in this repository.
|
||||
|
||||
## Deferred
|
||||
|
||||
A host-owned toast region that module UI posts into through the module UI SDK; how a link carries a
|
||||
return target (with the module navigation contract); exporting the focus trap; migrating the shell's
|
||||
navigation drawer onto the shared focus trap; replacing the device panel's revocation dialog with a
|
||||
`ConfirmationDialog` (it moves to Settings → Displays); a personal reduced-motion override; and the
|
||||
module repositories' own lint configuration.
|
||||
@@ -44,7 +44,7 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
|
||||
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
|
||||
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted (session lifetime superseded by 0056)
|
||||
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
||||
@@ -60,6 +60,11 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
|
||||
- [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted
|
||||
- [0051: Module packages declare their own license and carry its text](0051-module-package-license-material.md) — Accepted
|
||||
- [0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material](0052-reviewed-license-supplements.md) — Proposed
|
||||
- [0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material](0052-reviewed-license-supplements.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
|
||||
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
|
||||
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
|
||||
- [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted
|
||||
- [0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary](0058-dashboard-shell-navigation-and-lucide-icons.md) — Accepted
|
||||
- [0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs](0059-shared-interaction-primitives.md) — Accepted
|
||||
|
||||
@@ -343,7 +343,11 @@ permission the inventory does; organizing requires the new platform permission
|
||||
shell renders that arrangement and computes none of it.
|
||||
|
||||
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
|
||||
Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment
|
||||
Dashboard Home or widgets, no design system, and no client routing (client routing and an Overview
|
||||
landing page have since landed, see
|
||||
[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md), and the shell itself is now the
|
||||
collapsible sidebar, account menu, and phone drawer of
|
||||
[ADR 0058](decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). A production CSP and deployment
|
||||
for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
|
||||
pass 1 later supplies the host-owned sign-in flow described below.
|
||||
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
||||
@@ -588,7 +592,11 @@ registry that constructs is one that can be emitted as CSS without escaping. Tha
|
||||
exactly what makes a future theme editor implementable rather than aspirational: the values it would
|
||||
edit are already labelled, described, categorised, and enumerable. `@ekklesia/ui` is the small set of
|
||||
elements a real module dashboard cannot be built without, every value a token reference, with real
|
||||
`:focus-visible` treatment and status that never depends on colour alone. `pnpm boundaries` opens
|
||||
`:focus-visible` treatment and status that never depends on colour alone. It now also carries the
|
||||
shared interactions — a focus-trapping `Dialog` and `ConfirmationDialog`, a per-tree toast system,
|
||||
`ErrorState` with collapsed technical detail, skeletons, and `PageHeader`'s Back — and the lint
|
||||
refuses browser-native dialogs repository-wide
|
||||
([ADR 0059](decisions/0059-shared-interaction-primitives.md)). `pnpm boundaries` opens
|
||||
exactly two shared roles to modules **by name**, and pins both design packages beneath everything
|
||||
else. The Dashboard and display install the defaults at their document root; custom properties
|
||||
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
|
||||
@@ -692,10 +700,19 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
|
||||
reviewed supplements checked in with the packager add it
|
||||
([ADR 0052](decisions/0052-reviewed-license-supplements.md)). Each supplement matches one exact
|
||||
package name, version, and content digest; anything else fails closed.
|
||||
- **Corresponding source.** A native package that redistributes code under the LGPL, the MPL, or
|
||||
a similar licence is approved for public distribution only together with a sibling
|
||||
corresponding-source artifact. The artifact is built offline from a reviewed source recipe and a
|
||||
verified source cache, and linked to the package by digest
|
||||
([ADR 0055](decisions/0055-corresponding-source-artifacts.md)). Building and testing packages is
|
||||
unaffected.
|
||||
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
|
||||
special files, and escaping paths. It checks every digest and identity, the target and runtime,
|
||||
runs the platform's own module, schema, and migration validation, and loads the backend once in a
|
||||
short-lived process with an empty environment. Only then does it extract anything.
|
||||
short-lived process with an empty environment. Only then does it copy the package to a requested
|
||||
output directory. In the hardened container invocation, `/tmp` remains `noexec`; the temporary
|
||||
candidate lives on a dedicated, bounded executable tmpfs because a native addon's code must be
|
||||
mapped executable to load.
|
||||
- **A native package's host checks.** It is accepted only on a host whose operating system, CPU,
|
||||
C library family and version, and Node-API version Node.js itself reports as compatible.
|
||||
- **ELF files.** Every ELF file in it must be built for the target's machine.
|
||||
|
||||
+13
-1
@@ -233,7 +233,9 @@ password, URL, or stack reaches browser output or logging. The form offers **no
|
||||
because public registration is not a supported production flow. It does offer **Forgot password?**,
|
||||
which is now a real backend flow (ADR 0038).
|
||||
|
||||
Sign-out posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
|
||||
Sign-out is the account menu's one item, at the bottom of the sidebar or the phone drawer
|
||||
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). It
|
||||
posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
|
||||
resolves the principal again. The host does not equate hiding the shell with invalidating a session.
|
||||
During that transition the Dashboard subtree is unmounted, which invokes the generic module
|
||||
loader's ordinary cleanup and closes module-owned realtime subscriptions. If the mutation throws or
|
||||
@@ -259,6 +261,16 @@ redirect target and performs no post-login navigation, so external and protocol-
|
||||
have no redirect sink in this pass. See
|
||||
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
|
||||
|
||||
Since [ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md) that URL
|
||||
is a real page address: the API serves the Dashboard page at `/`, at `/settings` and `/account`, and
|
||||
at `/<module-id>` for every discovered module that declares a Dashboard UI (enabled or not) — each
|
||||
bare and with everything below it — with the same bytes, validator, and headers as `/`. A deep link
|
||||
opened while signed out therefore shows the sign-in form at that address and opens its own page once
|
||||
the person signs in — still with no return value and no redirect. Any other path — a module that is
|
||||
not installed, a display-only module, a look-alike such as `/settings-old` — remains the sanitized
|
||||
`404`. Module ids `account`, `api`, `assets`, `display`, and `settings` are reserved for the platform
|
||||
and refused by discovery (`MODULE_ID_RESERVED`).
|
||||
|
||||
### The four anonymous screens
|
||||
|
||||
The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
|
||||
|
||||
@@ -207,10 +207,10 @@ Two builds of the same commit produce byte-identical tarballs, and CI proves it
|
||||
|
||||
## Publishing the train
|
||||
|
||||
The registry is the Gitea npm registry under the `River_of_Glory_Church` owner:
|
||||
The registry is the Gitea npm registry under the `Zayin` owner:
|
||||
|
||||
```text
|
||||
https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
https://git.zayin.net/api/packages/Zayin/npm/
|
||||
```
|
||||
|
||||
| Who | May |
|
||||
@@ -272,7 +272,7 @@ Evidence that one train was published, not a second package catalog, and never a
|
||||
{
|
||||
"publicationRecord": 1,
|
||||
"packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" },
|
||||
"registry": "https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/",
|
||||
"registry": "https://git.zayin.net/api/packages/Zayin/npm/",
|
||||
"finalTag": "latest",
|
||||
"completedAt": "<ISO time>",
|
||||
"packages": [
|
||||
@@ -297,7 +297,7 @@ Evidence that one train was published, not a second package catalog, and never a
|
||||
|
||||
- **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by
|
||||
the publish step. Nobody needs a token to read or install.
|
||||
- **The publisher** is a dedicated account, member only of a `River_of_Glory_Church` team whose one
|
||||
- **The publisher** is a dedicated account, member only of a `Zayin` team whose one
|
||||
permission is Packages: Write. Its token has only the `write:package` scope. Gitea's
|
||||
`write:package` also allows deleting packages; the tool never does, and nothing else uses the
|
||||
token.
|
||||
@@ -319,7 +319,7 @@ guarantee:
|
||||
- **A published version is never deleted,** by anyone, for any reason short of a security incident.
|
||||
- **A version number is never reused with other bytes.** A version found on the registry with other
|
||||
bytes is burned: investigate how it got there, and publish the train under a new version.
|
||||
- **No package cleanup rule** may be configured for the `River_of_Glory_Church` owner.
|
||||
- **No package cleanup rule** may be configured for the `Zayin` owner.
|
||||
- **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests
|
||||
check that.
|
||||
|
||||
@@ -359,7 +359,7 @@ claim to be source that 0.1.2 never contained.
|
||||
Before it, an administrator of the Gitea instance:
|
||||
|
||||
1. creates the dedicated publisher account;
|
||||
2. creates a `River_of_Glory_Church` team, for example `package-publishers`, with only the Packages
|
||||
2. creates a `Zayin` team, for example `package-publishers`, with only the Packages
|
||||
unit at Write, and adds the account to it;
|
||||
3. creates a token for the account with only `write:package`, and stores it as the repository
|
||||
Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`;
|
||||
|
||||
@@ -484,7 +484,13 @@ before it is installed: its digest, its archive safety (no links, special files,
|
||||
escaping paths), every file's digest, and its identity and compatibility. The digest proves
|
||||
integrity, not who published the package; signing is not implemented. The verifier loads the backend
|
||||
in a short-lived process with an empty environment, so module code never sees a database URL or a
|
||||
secret while it is being checked.
|
||||
secret while it is being checked. That load executes package code: `noexec` is retained on the
|
||||
general `/tmp`, but native packages require the verifier's narrow, bounded execution scratch to be
|
||||
executable. The one-shot hardened verifier container—not the scratch mount—is the isolation
|
||||
boundary, and it is not claimed to be a perfect sandbox. Use the supported invocation in
|
||||
[`deploy/README.md`](../../deploy/README.md#installed-modules), which also keeps the root filesystem
|
||||
read-only, runs as the image's non-root user, disables networking and privilege escalation, and
|
||||
drops Linux capabilities.
|
||||
|
||||
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
||||
|
||||
|
||||
@@ -403,7 +403,10 @@ parser is exercised as the pure function it is, including that it names no desti
|
||||
no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia
|
||||
schema gained a token column and no identity code hashes or compares a token, that nothing
|
||||
interpolates a token, link, address, or body into a log line, that a device has no email, password,
|
||||
or verification concept, and that no browser source imports Better Auth or Core's mail.
|
||||
or verification concept, that no browser source imports Better Auth or Core's mail, and that the
|
||||
shell's only use of browser storage is the sidebar's expanded/collapsed mode — `localStorage` named
|
||||
only by the entry point, one write of one fixed key, and no storage API in any file that holds the
|
||||
reset token ([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)).
|
||||
|
||||
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
|
||||
injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
|
||||
|
||||
@@ -89,7 +89,7 @@ promises no API stability beyond the pre-1.0 SemVer policy. Only Ekklesia's rele
|
||||
the registry in your repository's committed `.npmrc`; that is all the configuration there is:
|
||||
|
||||
```ini
|
||||
@ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
@ekklesia:registry=https://git.zayin.net/api/packages/Zayin/npm/
|
||||
```
|
||||
|
||||
Then the module builds and packages like any other:
|
||||
@@ -296,9 +296,10 @@ The archive holds one directory named with your module id:
|
||||
- **What it does not do.** A supplement never changes the licence the package declares. The
|
||||
supplements cover `@img/sharp-libvips-linux-x64` 1.3.3 and 1.3.4, the libvips binaries that
|
||||
`sharp` 0.35.4 and 0.35.5 load; each matches only its own version and digest, and the packager's
|
||||
native proof builds with sharp 0.35.5. They record upstream sources, but whether the LGPL and MPL
|
||||
source obligations for those binaries are met is an open determination. A package that carries
|
||||
one is not yet treated as publicly distributable.
|
||||
native proof builds with sharp 0.35.5. A supplement identifies and verifies licence and notice
|
||||
material only. Source availability for copyleft native code is a separate requirement, met by a
|
||||
corresponding-source artifact
|
||||
([Distributing a native package publicly](#distributing-a-native-package-publicly)).
|
||||
- **Deterministic.** The same packager version and the same built inputs produce the same archive,
|
||||
byte for byte, wherever the checkout is.
|
||||
|
||||
@@ -317,13 +318,113 @@ require exact agreement with Core. The legacy facade has not been removed.
|
||||
`node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an
|
||||
Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without
|
||||
installing it. Pass `--extract-to <new directory>` to receive the verified module directory.
|
||||
[deploy/README.md](../../deploy/README.md#installed-modules) shows the whole installation.
|
||||
[deploy/README.md](../../deploy/README.md#installed-modules) shows the supported hardened container
|
||||
invocation; use it rather than inventing mount flags. Backend verification executes package code.
|
||||
Its container is therefore the isolation boundary: general `/tmp` remains `noexec`, while a narrow,
|
||||
bounded, ephemeral verifier scratch tmpfs permits execution so native addons can load.
|
||||
|
||||
The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An
|
||||
installed package normally has no source, so the platform cannot check it the same way. Your
|
||||
module's own CI is where its boundary rules, its production dependencies, and its behaviour inside
|
||||
Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)).
|
||||
|
||||
#### Distributing a native package publicly
|
||||
|
||||
Building and testing a native package needs nothing beyond the packager. **Distributing it
|
||||
publicly** does, when it redistributes native code under the GNU LGPL, the MPL, or a similar licence
|
||||
with source obligations
|
||||
([ADR 0055](../architecture/decisions/0055-corresponding-source-artifacts.md)).
|
||||
|
||||
- **Covered dependencies.** A native dependency is covered when its declared licence, or any
|
||||
component its reviewed supplement records inside it, carries source obligations. libvips is one:
|
||||
it declares `LGPL-3.0-or-later`, and cairo and several Rust crates inside it are under the MPL.
|
||||
- **The policy.** A package with covered dependencies may be distributed publicly only together with
|
||||
a corresponding-source artifact. The artifact holds the exact source, patches, build definitions,
|
||||
and rebuild and relink instructions for those dependencies, as bytes rather than links. If the
|
||||
artifact cannot be produced and verified, the package is not approved for public distribution.
|
||||
This is Ekklesia's project compliance policy, not legal advice or certification. A package that
|
||||
passes the gate meets Ekklesia's redistribution policy, and nothing more is claimed.
|
||||
- **Source recipes.** For each covered dependency version, a reviewed recipe is checked in beside
|
||||
the supplements, under `packages/module-pack/corresponding-sources/<package>/<version>/`. It holds
|
||||
`recipe.json` and its documents, notably `BUILDING.md`. The recipe names the exact package (name,
|
||||
version, content digest) and the exact supplement it was prepared with. It lists every component
|
||||
with its role (`compiled`, `build-input`, or `build-definition`), licence, licence material, and
|
||||
relationship to the binary. Every source file and patch is recorded by origin, size, and SHA-256,
|
||||
and every exclusion has a reason. Like supplements, recipes are reviewed data, and the mechanism
|
||||
knows no particular library.
|
||||
|
||||
Releasing a native package publicly takes three steps after packaging:
|
||||
|
||||
```bash
|
||||
# 1. Acquisition: the only step that uses the network. It fetches each recorded file once into a
|
||||
# cache keyed by SHA-256, refusing any response that differs from the recipe.
|
||||
pnpm exec ekklesia-corresponding-source acquire --package ../packages/<id>-<version>-linux-x64-glibc.tar.gz \
|
||||
--cache ~/.cache/ekklesia/corresponding-sources
|
||||
|
||||
# 2. Construction: offline. Every byte is verified against the recipe before anything is written.
|
||||
pnpm exec ekklesia-corresponding-source build --package ../packages/<id>-<version>-linux-x64-glibc.tar.gz \
|
||||
--cache ~/.cache/ekklesia/corresponding-sources --out ../packages
|
||||
|
||||
# 3. The public-distribution gate. Exit status 0 means approved.
|
||||
pnpm exec ekklesia-corresponding-source verify --package ../packages/<id>-<version>-linux-x64-glibc.tar.gz \
|
||||
--source ../packages/<id>-<version>-linux-x64-glibc-corresponding-source.tar.gz \
|
||||
--metadata ../packages/<id>-<version>-linux-x64-glibc.redistribution.json
|
||||
```
|
||||
|
||||
Construction writes four sibling release artifacts beside the package. The source archive never goes
|
||||
inside the Module Package, and `THIRD_PARTY_NOTICES` is unchanged.
|
||||
|
||||
```text
|
||||
<id>-<version>-linux-x64-glibc.tar.gz the Module Package (packager)
|
||||
<id>-<version>-linux-x64-glibc.tar.gz.sha256
|
||||
<id>-<version>-linux-x64-glibc-corresponding-source.tar.gz the corresponding source
|
||||
<id>-<version>-linux-x64-glibc-corresponding-source.tar.gz.sha256
|
||||
<id>-<version>-linux-x64-glibc.redistribution.json binary ↔ source, by digest
|
||||
<id>-<version>-linux-x64-glibc.source-notice.txt the release notice
|
||||
```
|
||||
|
||||
- **The source archive** is deterministic: sorted entries, fixed modes, ownership, and timestamps,
|
||||
and no machine-specific path. The same package and recipe produce the same bytes in any directory.
|
||||
- `corresponding-source.json` records every file with its SHA-256, and `SHA256SUMS` repeats them.
|
||||
- Each covered dependency has its own `dependencies/<package>/<version>/` directory, with
|
||||
`BUILDING.md`, the recipe, `PROVENANCE.md`, the supplement, and `sources/` and `patches/`.
|
||||
- **The redistribution metadata** answers "which exact source corresponds to this exact binary?"
|
||||
from digests. It records:
|
||||
- the package file and its SHA-256, the module identity, and the target;
|
||||
- each covered dependency's version and content digest, and the supplement and recipe it was
|
||||
assembled from;
|
||||
- the source archive's file name and SHA-256, and the SHA-256 of its manifest.
|
||||
- **The release notice** must be shown alongside the binary wherever it is offered. It states that
|
||||
the release includes third-party software under licences including the GNU LGPL and the MPL, and
|
||||
that corresponding source and build and relink materials for the covered native dependencies are
|
||||
in the accompanying artifact. It names that artifact by its exact file name and SHA-256.
|
||||
- **Fail-closed.** The gate refuses a package when:
|
||||
- a covered dependency has no recipe, or a recipe or supplement does not match its exact bytes;
|
||||
- the source archive is missing, non-canonical, or differs from its manifest;
|
||||
- the archive holds a file the manifest does not record, or a generated file names a local path;
|
||||
- the metadata or the notice does not name these exact files.
|
||||
|
||||
It also refuses bundled (not copied) copyleft code, which this mechanism does not cover. A package
|
||||
with no covered native code is approved without an artifact.
|
||||
- **Retention.** Keep a release's source artifact for as long as its binary release is distributed
|
||||
or supported. Never delete one automatically while the binary remains available. Never replace its
|
||||
bytes under the same name. A correction is a new release, or an explicitly versioned compliance
|
||||
artifact.
|
||||
|
||||
**Available today:** a recipe for `@img/sharp-libvips-linux-x64` 1.3.4, used by sharp 0.35.5. Its
|
||||
artifact is about 200 MB: 27 library archives, 5 patches, 353 Rust crates, and the Rust standard
|
||||
library source of the recorded toolchain. Following its `BUILDING.md` rebuilds
|
||||
`libvips-cpp.so.8.18.7` from that source alone. libvips 1.3.3 (sharp 0.35.4) has a supplement but no
|
||||
recipe, so a package with it builds and tests as before but is not approved for public distribution.
|
||||
`pnpm test:corresponding-source` runs the whole proof for the native proof module: acquisition into a
|
||||
verified cache, two byte-identical builds, the gate, and sharp running from the package.
|
||||
|
||||
**Adding a native dependency** follows the same path as libvips:
|
||||
- curate and review its supplement (ADR 0052);
|
||||
- curate and review its source recipe and `BUILDING.md`, recording every source input the
|
||||
dependency's build fetches;
|
||||
- prove both with the native packaging tests and `pnpm test:corresponding-source`.
|
||||
|
||||
### License
|
||||
|
||||
Ekklesia is licensed under the GNU Affero General Public License, version 3 or (at your option) any
|
||||
@@ -434,7 +535,12 @@ without executing module code:
|
||||
}
|
||||
```
|
||||
|
||||
Module, capability, and permission identifiers are lowercase and namespaced. A provided capability
|
||||
Module, capability, and permission identifiers are lowercase and namespaced. A module with a
|
||||
Dashboard UI is addressed at `/<module-id>` — `/calendar` for this one
|
||||
([ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)) — in the same
|
||||
top-level URL space as the platform's own pages, so a few ids are **reserved** and Core refuses a
|
||||
module that declares one (`MODULE_ID_RESERVED`, reported by discovery, `pnpm modules:validate`, and
|
||||
package verification): `account`, `api`, `assets`, `display`, and `settings`. A provided capability
|
||||
has its own exact semantic version because its public contract can evolve independently from the
|
||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||
may be absent; if it is installed, its capability version must be compatible.
|
||||
@@ -470,6 +576,17 @@ module's own HTTP routes, and creating relationship-derived grants automatically
|
||||
own business logic, are both still later work; this only makes a declared permission *usable*
|
||||
through Core's existing, generic surface.
|
||||
|
||||
### Icons in your module's UI
|
||||
|
||||
Lucide is Ekklesia's icon vocabulary
|
||||
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). If your
|
||||
UI needs icons, depend on `lucide-react` and import each icon by name, rather than drawing your own
|
||||
SVGs (module `.svg` presentation assets are rejected by `pnpm boundaries` anyway). Treat icons as
|
||||
decorative: give every control a text name, visible or visually hidden, and keep the icon
|
||||
`aria-hidden`. The Dashboard's navigation currently shows every module with one generic icon; a
|
||||
module will declare its own navigation icon through the module navigation contract (manifest v9), not
|
||||
yet.
|
||||
|
||||
### Your module's display name
|
||||
|
||||
`name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees
|
||||
@@ -782,6 +899,34 @@ Every other shared package stays rejected inside a module: `@ekklesia/api-client
|
||||
`@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
|
||||
import, and `pnpm boundaries` says so.
|
||||
|
||||
### Confirmations, toasts, errors, loading, and Back
|
||||
|
||||
`@ekklesia/ui` also carries the interactions DESIGN.md fixes for every screen
|
||||
([ADR 0059](../architecture/decisions/0059-shared-interaction-primitives.md)); use these rather than
|
||||
building your own:
|
||||
|
||||
- **`ConfirmationDialog`** for any action worth confirming. You choose the severity: `tone` stays
|
||||
`default` for a simple confirmation, becomes `"destructive"` for an action that matters, and adds
|
||||
`requiredPhrase` (`"DELETE"`, or the item's own name) only for the most serious ones. Name the
|
||||
action for what it does (`confirmLabel="Delete display"`), name the resource and the consequence in
|
||||
`description`, and set `busy` while the action runs.
|
||||
- **`ToastProvider`** once near the top of your tree, and `useToasts().show({ tone, title })` where
|
||||
something worth confirming happened. Errors stay until dismissed; everything else times out once it
|
||||
has been read.
|
||||
- **`ErrorState`** with a plain `title` and `description`, recovery `actions`, and — collapsed —
|
||||
`details` and a `reference`. Pass a sanitized code or message as `details`, never a caught exception.
|
||||
- **`SkeletonGroup`** labelled with what is loading, containing `Skeleton`s in the shape of the content.
|
||||
- **`PageHeader`** with `back` on a nested page. Your module decides where its own Back goes; a link
|
||||
into the Dashboard's Settings will carry a validated return target once the module navigation
|
||||
contract exists.
|
||||
|
||||
Never call `window.alert`, `window.confirm`, or `window.prompt`. Ekklesia's own lint refuses them;
|
||||
enable the same rule in your module's `biome.json`:
|
||||
|
||||
```json
|
||||
{ "linter": { "rules": { "suspicious": { "noAlert": "error" } } } }
|
||||
```
|
||||
|
||||
### Styling: a stylesheet compiled into your artifact
|
||||
|
||||
Author plain `.css` beside your UI source, import it, and declare it
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ekklesia",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -43,6 +43,7 @@
|
||||
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
|
||||
"test:root": "vitest run --config vitest.config.ts",
|
||||
"test:package-release": "bash scripts/test-package-release.sh",
|
||||
"test:corresponding-source": "bash scripts/test-corresponding-source.sh",
|
||||
"test:package-publication": "bash scripts/test-package-publication.sh",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "turbo run typecheck typecheck:root",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/contracts",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"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,
|
||||
* not characters, because that is what the wire format bounds. */
|
||||
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/design-tokens",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-database",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
// Committed launcher, deliberately not generated: see ekklesia-pack-module.mjs for why. It defers to
|
||||
// the compiled CLI at run time.
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const compiledCli = new URL("../dist/corresponding-source-cli.js", import.meta.url);
|
||||
|
||||
try {
|
||||
await import(compiledCli.href);
|
||||
} catch (error) {
|
||||
if (error?.code === "ERR_MODULE_NOT_FOUND") {
|
||||
process.stderr.write(
|
||||
`@ekklesia/module-pack is not built yet (${fileURLToPath(compiledCli)}).\n` +
|
||||
"Run `pnpm build` from the repository root first.\n",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
# Rebuilding `@img/sharp-libvips-linux-x64` 1.3.4
|
||||
|
||||
This directory is the corresponding source for one redistributed native binary. These instructions
|
||||
say how to rebuild it from the source here, and how to use a rebuilt or modified library in place of
|
||||
the redistributed one. They are technical instructions. They are not a legal opinion, and they have
|
||||
not been legally certified.
|
||||
|
||||
## What this corresponds to
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Package | `@img/sharp-libvips-linux-x64` 1.3.4, from the npm registry |
|
||||
| Declared licence | `LGPL-3.0-or-later` |
|
||||
| Content digest | `ekklesia-content-v1:sha256:2927fe11493461fa34847191d0b9b1c6441007c3e99f306e5708cb07fb0a5e59` |
|
||||
| Binary | `lib/libvips-cpp.so.8.18.7` (SONAME `libvips-cpp.so.8.18.7`) |
|
||||
| Target | Linux, x86-64, glibc 2.28 or newer |
|
||||
| Used by | `sharp` 0.35.5, through `@img/sharp-linux-x64` 0.35.5 |
|
||||
|
||||
`lib/libvips-cpp.so.8.18.7` is one shared object. libvips and the libraries it uses are built as
|
||||
static libraries and linked into it: 27 C and C++ libraries, glib's bundled gvdb, librsvg with its
|
||||
Rust crates, and the Rust standard library. `recipe.json` lists every component, its licence, its
|
||||
source file and patches with their SHA-256, and its relationship to the binary. `PROVENANCE.md`
|
||||
records how all of this was established, and `licenses/` holds the reviewed licence and notice
|
||||
material.
|
||||
|
||||
## What is here
|
||||
|
||||
- `sources/sharp-libvips-1.3.4.tar.gz`: the build definitions that produced the binary. They are
|
||||
`build.sh`, `build/posix.sh`, `platforms/linux-x64/` (a Dockerfile and its toolchain files), and
|
||||
`versions.properties`.
|
||||
- `sources/*.tar.*`: the source archive of every compiled library, exactly as `build/posix.sh`
|
||||
downloads it.
|
||||
- `patches/`: the five patches `build/posix.sh` applies: glib, mozjpeg, librsvg, libultrahdr, and
|
||||
libvips.
|
||||
- `sources/crates/`: all 353 Rust crates librsvg's resolved `Cargo.lock` records.
|
||||
- 147 are compiled into the binary.
|
||||
- 17 are needed only to build it: build-script dependencies, and one procedural macro that adds no
|
||||
code.
|
||||
- 189 serve librsvg's other workspace members, its tests, or other platforms. Cargo resolves the
|
||||
whole workspace lockfile even when it builds one package, so it needs them present to build
|
||||
offline. None of them is compiled into the binary.
|
||||
|
||||
`recipe.json` records each crate's role.
|
||||
- `sources/rust/rust-src-nightly-2026-09-27.tar.xz`: the standard-library source of the Rust toolchain
|
||||
that built librsvg, `rustc 1.101.0-nightly (75a75c3e0 2026-09-26)`.
|
||||
- `build/`: the resolved librsvg `Cargo.lock`, and the two scripts below that point the unchanged build
|
||||
logic at these files.
|
||||
|
||||
Every file's SHA-256 is in `recipe.json` and in the archive's top-level `corresponding-source.json`.
|
||||
Check them before building.
|
||||
|
||||
## How the original was built
|
||||
|
||||
sharp-libvips builds the binary in a Rocky Linux 8 container, which pins glibc 2.28:
|
||||
|
||||
```sh
|
||||
./build.sh linux-x64
|
||||
# = docker build --pull -t vips-dev-linux-x64 platforms/linux-x64
|
||||
# docker run --rm -v "$PWD:/packaging" vips-dev-linux-x64 sh -c /packaging/build/posix.sh
|
||||
```
|
||||
|
||||
`platforms/linux-x64/Dockerfile` installs the toolchain: gcc-toolset-15, cmake, nasm, pkgconf, meson
|
||||
and ninja (from pip), a nightly Rust toolchain through rustup, and cargo-c. `build/posix.sh` then
|
||||
downloads each library's archive, applies the patches, builds every library as a static library into
|
||||
`/target`, and links libvips-cpp. It packs the result as `sharp-libvips-linux-x64.tar.gz`, whose
|
||||
`lib/libvips-cpp.so.8.18.7` is the file the npm package ships. Meson runs with
|
||||
`wrap_mode = 'nofallback'` (`platforms/linux-x64/meson.ini`), so it downloads no subproject. The only
|
||||
forced fallback, gvdb, builds from the copy inside glib's archive.
|
||||
|
||||
## Rebuilding from this source
|
||||
|
||||
You need a Linux x86-64 host with Docker. Building the container image needs network access to fetch
|
||||
the toolchain (Rocky Linux packages, rustup, pip, and crates.io for cargo-c). Source for the library
|
||||
itself comes only from this directory.
|
||||
|
||||
1. **Unpack the build definitions.**
|
||||
|
||||
```sh
|
||||
tar -xzf sources/sharp-libvips-1.3.4.tar.gz # creates sharp-libvips-1.3.4/
|
||||
```
|
||||
|
||||
2. **Point the build at this source.**
|
||||
|
||||
```sh
|
||||
sh build/prepare-offline.sh sharp-libvips-1.3.4
|
||||
```
|
||||
|
||||
It copies the archives and patches into `sharp-libvips-1.3.4/offline/`. It unpacks the crates into
|
||||
a vendored directory source; Cargo checks each against the lockfile's checksum. It then makes three
|
||||
mechanical edits:
|
||||
- in `build/posix.sh`, `CURL` becomes `offline/offline-curl.sh`. That script answers each URL the
|
||||
script would download with its local copy, using `build/url-map.tsv`, and fails for any other URL;
|
||||
- in `build/posix.sh`, `cargo update --workspace` becomes a copy of the resolved `Cargo.lock`. Cargo
|
||||
is set offline and uses the vendored crates;
|
||||
- in `platforms/linux-x64/Dockerfile`, the Rust toolchain is pinned to `nightly-2026-09-27`, the
|
||||
toolchain whose `rustc` built the original. The upstream file installs whatever nightly is
|
||||
current.
|
||||
|
||||
Nothing that is compiled changes: every patch is still applied by `build/posix.sh`, exactly as
|
||||
upstream.
|
||||
|
||||
3. **Build.**
|
||||
|
||||
```sh
|
||||
cd sharp-libvips-1.3.4
|
||||
./build.sh linux-x64
|
||||
```
|
||||
|
||||
The result is `sharp-libvips-1.3.4/sharp-libvips-linux-x64.tar.gz`. Its
|
||||
`lib/libvips-cpp.so.8.18.7` is the rebuilt library. A full build takes from tens of minutes to an
|
||||
hour, depending on the host.
|
||||
|
||||
**Modifying a component.** Before step 3, change the source the build uses. Then `build/posix.sh`
|
||||
compiles your version in place of the original:
|
||||
- for a C or C++ library, replace its archive in `offline/files/` under the same name, or add a patch
|
||||
to the matching step of `build/posix.sh`;
|
||||
- for a Rust crate, edit it under `offline/vendor/`. The `.cargo-checksum.json` files list no
|
||||
per-file checksums, so an edited crate still builds.
|
||||
|
||||
The result is not byte-identical to the redistributed binary: a compiler toolchain, timestamps, and
|
||||
paths that were not recorded can all differ. It is built from the same source, by the same
|
||||
definitions, for the same target.
|
||||
|
||||
## Using a rebuilt or modified library
|
||||
|
||||
sharp's native addon, `@img/sharp-linux-x64/lib/sharp-linux-x64-0.35.5.node`, links
|
||||
`libvips-cpp.so.8.18.7` dynamically. Its RPATH begins with `$ORIGIN/../../sharp-libvips-linux-x64/lib`.
|
||||
In an installed or extracted Ekklesia Module Package, that resolves to:
|
||||
|
||||
```text
|
||||
<module directory>/node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.18.7
|
||||
```
|
||||
|
||||
To use your library, replace that file with your build's `lib/libvips-cpp.so.8.18.7`. Keep the file
|
||||
name and SONAME, and keep libvips's C++ API compatible with libvips 8.18, which sharp 0.35.5 is
|
||||
compiled against. Then restart the process that loads the module. No other file changes, and the
|
||||
addon does not need rebuilding.
|
||||
|
||||
`ldd <module directory>/node_modules/@img/sharp-linux-x64/lib/sharp-linux-x64-0.35.5.node` shows which
|
||||
`libvips-cpp.so.8.18.7` the addon resolves.
|
||||
|
||||
A module package's own `ekklesia.package.json` records the SHA-256 of each file it shipped, so a
|
||||
modified tree is no longer the package Ekklesia verified at installation. Install it as your own
|
||||
build.
|
||||
Generated
Vendored
+3440
File diff suppressed because it is too large
Load Diff
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
# Stands in for curl in sharp-libvips's build/posix.sh, so the build reads only this artifact's
|
||||
# sources. Given the URL posix.sh would download, it prints the recorded local copy, or with -O
|
||||
# writes it to the current directory under the URL's file name. A URL with no recorded copy fails:
|
||||
# the build never reaches the network for source.
|
||||
set -eu
|
||||
write=""
|
||||
url=""
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
-O) write=yes ;;
|
||||
-*) ;;
|
||||
*) url="$argument" ;;
|
||||
esac
|
||||
done
|
||||
file="$(awk -F '\t' -v url="$url" '$1 == url { print $2 }' /packaging/offline/url-map.tsv)"
|
||||
if [ -z "$file" ]; then
|
||||
echo "offline-curl: no recorded source for $url" >&2
|
||||
exit 22
|
||||
fi
|
||||
if [ -n "$write" ]; then
|
||||
cp "/packaging/offline/files/$file" "./${url##*/}"
|
||||
else
|
||||
cat "/packaging/offline/files/$file"
|
||||
fi
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/sh
|
||||
# Prepares an extracted sharp-libvips v1.3.4 source tree to build lib/libvips-cpp.so.8.18.7 from this
|
||||
# artifact's sources alone. BUILDING.md describes each step.
|
||||
#
|
||||
# sh build/prepare-offline.sh <extracted sharp-libvips-1.3.4 directory>
|
||||
#
|
||||
# Only the source acquisition changes; nothing that is compiled does:
|
||||
# - every archive and patch build/posix.sh downloads is read from this artifact (offline-curl.sh);
|
||||
# - librsvg's Rust crates come from this artifact as a vendored directory source, and its lockfile
|
||||
# is the resolved one recorded here instead of a fresh `cargo update` against crates.io;
|
||||
# - platforms/linux-x64/Dockerfile installs the exact nightly Rust toolchain the binary records.
|
||||
set -eu
|
||||
here="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
tree="$(cd "$1" && pwd)"
|
||||
[ -f "$tree/build/posix.sh" ] && [ -f "$tree/platforms/linux-x64/Dockerfile" ] || {
|
||||
echo "prepare-offline: $1 is not an extracted sharp-libvips source tree." >&2
|
||||
exit 1
|
||||
}
|
||||
offline="$tree/offline"
|
||||
rm -rf "$offline"
|
||||
mkdir -p "$offline/files" "$offline/vendor"
|
||||
|
||||
cp "$here"/sources/*.tar.* "$here"/patches/*.patch "$offline/files/"
|
||||
cp "$tree/THIRD-PARTY-NOTICES.md" "$offline/files/THIRD-PARTY-NOTICES.md"
|
||||
cp "$here/build/url-map.tsv" "$here/build/Cargo.lock" "$offline/"
|
||||
cp "$here/build/offline-curl.sh" "$offline/offline-curl.sh"
|
||||
chmod 755 "$offline/offline-curl.sh"
|
||||
|
||||
for crate in "$here"/sources/crates/*.crate; do
|
||||
tar -xzf "$crate" -C "$offline/vendor"
|
||||
name="$(basename "$crate" .crate)"
|
||||
checksum="$(sha256sum "$crate" | cut -d ' ' -f 1)"
|
||||
printf '{"files":{},"package":"%s"}\n' "$checksum" > "$offline/vendor/$name/.cargo-checksum.json"
|
||||
done
|
||||
cat > "$offline/cargo-config.toml" <<'CONFIG'
|
||||
[source.crates-io]
|
||||
replace-with = "vendored-sources"
|
||||
|
||||
[source.vendored-sources]
|
||||
directory = "/packaging/offline/vendor"
|
||||
|
||||
[net]
|
||||
offline = true
|
||||
CONFIG
|
||||
|
||||
posix="$tree/build/posix.sh"
|
||||
grep -q '^CURL="curl ' "$posix" && grep -q '^cargo update --workspace$' "$posix" || {
|
||||
echo "prepare-offline: build/posix.sh is not the v1.3.4 script this artifact was prepared for." >&2
|
||||
exit 1
|
||||
}
|
||||
sed -i \
|
||||
-e 's|^CURL="curl .*"$|CURL="/packaging/offline/offline-curl.sh"\nexport CARGO_NET_OFFLINE=true\nmkdir -p "${CARGO_HOME:-$HOME/.cargo}" \&\& cp /packaging/offline/cargo-config.toml "${CARGO_HOME:-$HOME/.cargo}/config.toml"|' \
|
||||
-e 's|^cargo update --workspace$|cp /packaging/offline/Cargo.lock Cargo.lock|' \
|
||||
"$posix"
|
||||
dockerfile="$tree/platforms/linux-x64/Dockerfile"
|
||||
grep -q -- '--default-toolchain nightly ' "$dockerfile" || {
|
||||
echo "prepare-offline: platforms/linux-x64/Dockerfile is not the v1.3.4 one." >&2
|
||||
exit 1
|
||||
}
|
||||
sed -i 's|--default-toolchain nightly |--default-toolchain nightly-2026-09-27 |' "$dockerfile"
|
||||
echo "prepare-offline: $tree builds from this artifact's sources."
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
https://cairographics.org/releases/cairo-1.18.6.tar.xz cairo-1.18.6.tar.xz
|
||||
https://cairographics.org/releases/pixman-0.46.4.tar.gz pixman-0.46.4.tar.gz
|
||||
https://download.gnome.org/sources/glib/2.90/glib-2.90.0.tar.xz glib-2.90.0.tar.xz
|
||||
https://download.gnome.org/sources/librsvg/2.63/librsvg-2.63.2.tar.xz librsvg-2.63.2.tar.xz
|
||||
https://download.gnome.org/sources/libxml2/2.15/libxml2-2.15.4.tar.xz libxml2-2.15.4.tar.xz
|
||||
https://download.gnome.org/sources/pango/1.58/pango-1.58.2.tar.xz pango-1.58.2.tar.xz
|
||||
https://gist.github.com/kleisauke/284d685efa00908da99ea6afbaaf39ae/raw/bdad5489a61c217850631571caf57f5db6ea8b2c/glib-without-gregex.patch glib-without-gregex.patch
|
||||
https://gist.githubusercontent.com/lovell/313a6901e9db1bf285f2a1f1180499e4/raw/3988223c7dfa4d22745d9392034b0117abef1446/libvips-cpp-soversion.patch libvips-cpp-soversion.patch
|
||||
https://github.com/GNOME/librsvg/commit/9106011db93d701728ac2c9da50c9ab2c1bb5dc6.patch librsvg-9106011db93d701728ac2c9da50c9ab2c1bb5dc6.patch
|
||||
https://github.com/dloebl/cgif/archive/v0.5.4.tar.gz cgif-0.5.4.tar.gz
|
||||
https://github.com/freetype/freetype/archive/VER-2-14-3.tar.gz freetype-2.14.3.tar.gz
|
||||
https://github.com/fribidi/fribidi/releases/download/v1.0.17/fribidi-1.0.17.tar.xz fribidi-1.0.17.tar.xz
|
||||
https://github.com/google/highway/archive/1.4.0.tar.gz highway-1.4.0.tar.gz
|
||||
https://github.com/google/libultrahdr/archive/v2.0.2.tar.gz libultrahdr-2.0.2.tar.gz
|
||||
https://github.com/harfbuzz/harfbuzz/archive/14.5.0.tar.gz harfbuzz-14.5.0.tar.gz
|
||||
https://github.com/libarchive/libarchive/releases/download/v3.8.9/libarchive-3.8.9.tar.xz libarchive-3.8.9.tar.xz
|
||||
https://github.com/libexif/libexif/releases/download/v0.6.26/libexif-0.6.26.tar.xz libexif-0.6.26.tar.xz
|
||||
https://github.com/libexpat/libexpat/releases/download/R_2_8_5/expat-2.8.5.tar.xz expat-2.8.5.tar.xz
|
||||
https://github.com/libffi/libffi/releases/download/v3.8.0/libffi-3.8.0.tar.gz libffi-3.8.0.tar.gz
|
||||
https://github.com/libvips/libvips/releases/download/v8.18.7/vips-8.18.7.tar.xz vips-8.18.7.tar.xz
|
||||
https://github.com/lovell/libimagequant/archive/v2.4.1.tar.gz libimagequant-2.4.1.tar.gz
|
||||
https://github.com/mm2/Little-CMS/releases/download/lcms2.19.1/lcms2-2.19.1.tar.gz lcms2-2.19.1.tar.gz
|
||||
https://github.com/mozilla/mozjpeg/archive/0826579.tar.gz mozjpeg-0826579.tar.gz
|
||||
https://github.com/mozilla/mozjpeg/commit/f90668e0e4fb79c81e1f24a0ccc0e2090af761bf.patch mozjpeg-f90668e0e4fb79c81e1f24a0ccc0e2090af761bf.patch
|
||||
https://github.com/pnggroup/libpng/archive/v1.6.58.tar.gz libpng-1.6.58.tar.gz
|
||||
https://github.com/strukturag/libheif/releases/download/v1.23.5/libheif-1.23.5.tar.gz libheif-1.23.5.tar.gz
|
||||
https://github.com/zlib-ng/zlib-ng/archive/2.3.3.tar.gz zlib-ng-2.3.3.tar.gz
|
||||
https://gitlab.com/libtiff/libtiff/-/archive/v4.7.2/libtiff-v4.7.2.tar.gz libtiff-v4.7.2.tar.gz
|
||||
https://gitlab.freedesktop.org/fontconfig/fontconfig/-/archive/2.18.3/fontconfig-2.18.3.tar.gz fontconfig-2.18.3.tar.gz
|
||||
https://patch-diff.githubusercontent.com/raw/google/libultrahdr/pull/383.patch libultrahdr-pull-383.patch
|
||||
https://raw.githubusercontent.com/lovell/sharp-libvips/main/THIRD-PARTY-NOTICES.md THIRD-PARTY-NOTICES.md
|
||||
https://storage.googleapis.com/aom-releases/libaom-3.15.1.tar.gz libaom-3.15.1.tar.gz
|
||||
https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.6.0.tar.gz libwebp-1.6.0.tar.gz
|
||||
|
+7538
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "@ekklesia/module-pack",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"bin",
|
||||
"corresponding-sources",
|
||||
"dist",
|
||||
"license-supplements",
|
||||
"licenses"
|
||||
],
|
||||
"bin": {
|
||||
"ekklesia-corresponding-source": "./bin/ekklesia-corresponding-source.mjs",
|
||||
"ekklesia-pack-module": "./bin/ekklesia-pack-module.mjs"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
acquireCorrespondingSources,
|
||||
buildCorrespondingSource,
|
||||
CorrespondingSourceError,
|
||||
verifyRedistribution,
|
||||
} from "./corresponding-source.js";
|
||||
|
||||
/**
|
||||
* `ekklesia-corresponding-source` — corresponding-source artifacts for native packages (ADR 0055).
|
||||
*
|
||||
* ekklesia-corresponding-source acquire (--package <module package> | --recipe <name>@<version>) --cache <directory>
|
||||
* Downloads every recorded source file the package's covered native dependencies (or the named
|
||||
* recipe) need into the verified cache. The only subcommand that uses the network.
|
||||
* ekklesia-corresponding-source build --package <module package> --cache <directory> --out <directory>
|
||||
* Offline. Writes <package>-corresponding-source.tar.gz, its .sha256, <package>.redistribution.json,
|
||||
* and <package>.source-notice.txt.
|
||||
* ekklesia-corresponding-source verify --package <module package> [--source <archive> --metadata <json>]
|
||||
* The public-distribution gate. Exits 0 only when the package is approved.
|
||||
*/
|
||||
|
||||
const USAGE = [
|
||||
"Usage:",
|
||||
" ekklesia-corresponding-source acquire (--package <archive> | --recipe <name>@<version>) --cache <directory>",
|
||||
" ekklesia-corresponding-source build --package <archive> --cache <directory> --out <directory>",
|
||||
" ekklesia-corresponding-source verify --package <archive> [--source <archive> --metadata <file>]",
|
||||
].join("\n");
|
||||
|
||||
const OPTIONS: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
||||
acquire: ["--package", "--recipe", "--cache"],
|
||||
build: ["--package", "--cache", "--out"],
|
||||
verify: ["--package", "--source", "--metadata"],
|
||||
});
|
||||
const REQUIRED: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
||||
acquire: ["--cache"],
|
||||
build: ["--package", "--cache", "--out"],
|
||||
verify: ["--package"],
|
||||
});
|
||||
|
||||
class ArgumentsError extends Error {}
|
||||
|
||||
export function parseCorrespondingSourceArguments(argv: readonly string[]): {
|
||||
readonly command: "acquire" | "build" | "verify";
|
||||
readonly values: ReadonlyMap<string, string>;
|
||||
} {
|
||||
const [command, ...rest] = argv;
|
||||
const allowed = command === undefined ? undefined : OPTIONS[command];
|
||||
if (allowed === undefined) {
|
||||
throw new ArgumentsError(USAGE);
|
||||
}
|
||||
const values = new Map<string, string>();
|
||||
for (let index = 0; index < rest.length; index += 2) {
|
||||
const option = rest[index] ?? "";
|
||||
const value = rest[index + 1];
|
||||
if (
|
||||
!allowed.includes(option) ||
|
||||
value === undefined ||
|
||||
value.startsWith("--") ||
|
||||
values.has(option)
|
||||
) {
|
||||
throw new ArgumentsError(USAGE);
|
||||
}
|
||||
values.set(option, option === "--recipe" ? value : path.resolve(value));
|
||||
}
|
||||
if (command === "acquire" && values.has("--package") === values.has("--recipe")) {
|
||||
throw new ArgumentsError(USAGE);
|
||||
}
|
||||
if ((REQUIRED[command as string] ?? []).some((option) => !values.has(option))) {
|
||||
throw new ArgumentsError(USAGE);
|
||||
}
|
||||
return { command: command as "acquire" | "build" | "verify", values };
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
try {
|
||||
const { command, values } = parseCorrespondingSourceArguments(process.argv.slice(2));
|
||||
const packageArchivePath = values.get("--package") ?? "";
|
||||
if (command === "acquire") {
|
||||
const recipe = values.get("--recipe");
|
||||
const at = recipe?.lastIndexOf("@") ?? -1;
|
||||
if (recipe !== undefined && at <= 0) {
|
||||
throw new ArgumentsError(USAGE);
|
||||
}
|
||||
const result = await acquireCorrespondingSources({
|
||||
cacheDirectory: values.get("--cache") ?? "",
|
||||
...(recipe === undefined
|
||||
? { packageArchivePath }
|
||||
: { recipes: [{ name: recipe.slice(0, at), version: recipe.slice(at + 1) }] }),
|
||||
});
|
||||
process.stdout.write(
|
||||
`Source cache holds all ${result.files} recorded files (${result.bytes} bytes); fetched ${result.fetched}.\n`,
|
||||
);
|
||||
} else if (command === "build") {
|
||||
const result = await buildCorrespondingSource({
|
||||
cacheDirectory: values.get("--cache") ?? "",
|
||||
outDirectory: values.get("--out") ?? "",
|
||||
packageArchivePath,
|
||||
});
|
||||
if (!result.required) {
|
||||
process.stdout.write(
|
||||
"The package redistributes no covered native code: no artifact is needed.\n",
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`Wrote ${path.basename(result.sourceArchivePath ?? "")}, ${result.sourceBytes} bytes\nsha256 ${result.sourceSha256}\n`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const metadataPath = values.get("--metadata");
|
||||
const sourceArchivePath = values.get("--source");
|
||||
const approval = await verifyRedistribution({
|
||||
packageArchivePath,
|
||||
...(metadataPath === undefined ? {} : { metadataPath }),
|
||||
...(sourceArchivePath === undefined ? {} : { sourceArchivePath }),
|
||||
});
|
||||
process.stdout.write(
|
||||
approval.correspondingSource === undefined
|
||||
? `Approved: ${approval.package.file} redistributes no covered native code.\n`
|
||||
: `Approved: ${approval.package.file} with ${approval.correspondingSource.file} (sha256 ${approval.correspondingSource.sha256}).\n`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (error instanceof ArgumentsError) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
return 64;
|
||||
}
|
||||
if (error instanceof CorrespondingSourceError) {
|
||||
process.stderr.write(
|
||||
`${error.code}: ${error.message}${error.subject ? ` (${error.subject})` : ""}\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
process.stderr.write("ekklesia-corresponding-source failed unexpectedly.\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
const entrypoint = process.argv[1];
|
||||
// Run through the committed launcher, which imports this file, or directly.
|
||||
return (
|
||||
entrypoint !== undefined &&
|
||||
(import.meta.url === pathToFileURL(path.resolve(entrypoint)).href ||
|
||||
path.basename(entrypoint) === "ekklesia-corresponding-source.mjs" ||
|
||||
path.basename(entrypoint) === "ekklesia-corresponding-source")
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
process.exitCode = await main();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1,2 @@
|
||||
export * from "./corresponding-source.js";
|
||||
export * from "./pack-module.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { gunzipSync, gzipSync } from "node:zlib";
|
||||
|
||||
/**
|
||||
* The deterministic archive encoding of Ekklesia Module Package v1 (ADR 0049).
|
||||
@@ -107,3 +107,101 @@ export function deterministicTarGz(topLevel: string, files: ReadonlyMap<string,
|
||||
parts.push(Buffer.alloc(BLOCK * 2));
|
||||
return gzipSync(Buffer.concat(parts), { level: GZIP_LEVEL });
|
||||
}
|
||||
|
||||
/** One entry of a ustar archive, as read back. */
|
||||
export interface TarEntry {
|
||||
readonly path: string;
|
||||
readonly directory: boolean;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
export class TarFormatError extends Error {
|
||||
constructor() {
|
||||
super("An archive is not in Ekklesia's deterministic ustar encoding.");
|
||||
this.name = "TarFormatError";
|
||||
}
|
||||
}
|
||||
|
||||
function field(block: Buffer, offset: number, length: number): string {
|
||||
const raw = block.subarray(offset, offset + length);
|
||||
const end = raw.indexOf(0);
|
||||
return raw.subarray(0, end === -1 ? length : end).toString("latin1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an archive written by `deterministicTarGz` back into its entries, refusing anything that
|
||||
* encoding never produces: a link or special file, another header type, a bad checksum, or data
|
||||
* after the end blocks. Whether the archive is byte-for-byte canonical is checked separately, by
|
||||
* writing the entries again (`isCanonicalTarGz`).
|
||||
*/
|
||||
export function readTarGz(archive: Buffer): readonly TarEntry[] {
|
||||
let raw: Buffer;
|
||||
try {
|
||||
raw = gunzipSync(archive);
|
||||
} catch {
|
||||
throw new TarFormatError();
|
||||
}
|
||||
const entries: TarEntry[] = [];
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
if (offset + BLOCK > raw.length) {
|
||||
throw new TarFormatError();
|
||||
}
|
||||
const block = raw.subarray(offset, offset + BLOCK);
|
||||
if (block.every((byte) => byte === 0)) {
|
||||
break;
|
||||
}
|
||||
const stored = Number.parseInt(field(block, 148, 8).trim() || "x", 8);
|
||||
let checksum = 0;
|
||||
for (let index = 0; index < BLOCK; index += 1) {
|
||||
checksum += index >= 148 && index < 156 ? 32 : (block[index] ?? 0);
|
||||
}
|
||||
const typeflag = field(block, 156, 1);
|
||||
const size = Number.parseInt(field(block, 124, 12) || "x", 8);
|
||||
if (
|
||||
stored !== checksum ||
|
||||
(typeflag !== "0" && typeflag !== "5") ||
|
||||
!Number.isSafeInteger(size)
|
||||
) {
|
||||
throw new TarFormatError();
|
||||
}
|
||||
const name = field(block, 0, 100);
|
||||
const prefix = field(block, 345, 155);
|
||||
const joined = prefix === "" ? name : `${prefix}/${name}`;
|
||||
const directory = typeflag === "5";
|
||||
const start = offset + BLOCK;
|
||||
if (start + size > raw.length) {
|
||||
throw new TarFormatError();
|
||||
}
|
||||
entries.push({
|
||||
data: Buffer.from(raw.subarray(start, start + size)),
|
||||
directory,
|
||||
path: directory ? joined.replace(/\/$/u, "") : joined,
|
||||
});
|
||||
offset = start + Math.ceil(size / BLOCK) * BLOCK;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Whether `archive` is exactly what `deterministicTarGz` writes for its own entries: the same
|
||||
* order, headers, and padding. Compared uncompressed, so a different zlib cannot matter. */
|
||||
export function isCanonicalTarGz(archive: Buffer, entries: readonly TarEntry[]): boolean {
|
||||
const topLevel = entries[0];
|
||||
if (topLevel === undefined || !topLevel.directory || topLevel.path.includes("/")) {
|
||||
return false;
|
||||
}
|
||||
const files = new Map<string, Buffer>();
|
||||
for (const entry of entries) {
|
||||
if (!entry.directory) {
|
||||
if (!entry.path.startsWith(`${topLevel.path}/`)) {
|
||||
return false;
|
||||
}
|
||||
files.set(entry.path.slice(topLevel.path.length + 1), entry.data);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return gunzipSync(deterministicTarGz(topLevel.path, files)).equals(gunzipSync(archive));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { currentModulePackageHost, verifyModulePackage } from "@ekklesia/core/modules";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCorrespondingSource,
|
||||
CorrespondingSourceError,
|
||||
loadSourceRecipes,
|
||||
verifyRedistribution,
|
||||
} from "../src/corresponding-source.js";
|
||||
import { packModule } from "../src/index.js";
|
||||
import { NATIVE_TARGETS, nativeClosure } from "../src/native.js";
|
||||
import {
|
||||
contentDigest,
|
||||
DEFAULT_SUPPLEMENTS_DIRECTORY,
|
||||
loadLicenseSupplements,
|
||||
} from "../src/supplements.js";
|
||||
import { readTarGz } from "../src/tar.js";
|
||||
import {
|
||||
cleanupFixtures,
|
||||
installedPackageFile,
|
||||
nativeFixtureModule,
|
||||
SOURCE_REVISION,
|
||||
scratchDirectory,
|
||||
} from "./support/fixture-module.js";
|
||||
|
||||
/**
|
||||
* ADR 0055's first complete proof: the native proof module with sharp 0.35.5, whose libvips 1.3.4
|
||||
* binary contains LGPL and MPL components, and its real corresponding-source artifact.
|
||||
*
|
||||
* The recipe checks run everywhere. Building the artifact needs the recorded sources in a verified
|
||||
* cache, which `pnpm test:corresponding-source` acquires once and passes in as
|
||||
* EKKLESIA_CORRESPONDING_SOURCE_CACHE; without it, that part is skipped rather than downloading
|
||||
* about 200 MB on every run.
|
||||
*/
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repositoryRoot = path.resolve(packageRoot, "..", "..");
|
||||
const LIBVIPS = "@img/sharp-libvips-linux-x64";
|
||||
const cache = process.env.EKKLESIA_CORRESPONDING_SOURCE_CACHE;
|
||||
const host = currentModulePackageHost();
|
||||
const onTargetHost =
|
||||
host.platform === "linux" && host.arch === "x64" && host.libc?.family === "glibc";
|
||||
|
||||
afterEach(cleanupFixtures);
|
||||
|
||||
// `pnpm test:corresponding-source` asks for the full proof: then a missing cache is a failure, not a
|
||||
// skip.
|
||||
it.runIf(process.env.EKKLESIA_CORRESPONDING_SOURCE_REQUIRED === "1")(
|
||||
"has the verified source cache it was asked to use, on a linux-x64-glibc host",
|
||||
() => {
|
||||
expect(onTargetHost).toBe(true);
|
||||
expect(cache !== undefined && existsSync(cache)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
describe("the libvips 1.3.4 source recipe", () => {
|
||||
it("loads intact, matches its reviewed supplement exactly, and pins the installed package", async () => {
|
||||
const recipes = await loadSourceRecipes();
|
||||
const [loaded] = recipes.get(LIBVIPS) ?? [];
|
||||
expect(loaded?.recipe.package).toMatchObject({
|
||||
license: "LGPL-3.0-or-later",
|
||||
target: "linux-x64-glibc",
|
||||
version: "1.3.4",
|
||||
});
|
||||
|
||||
// Prepared against exactly the reviewed supplement for these package bytes.
|
||||
const supplementJson = await readFile(
|
||||
path.join(DEFAULT_SUPPLEMENTS_DIRECTORY, LIBVIPS, "1.3.4", "supplement.json"),
|
||||
);
|
||||
expect(loaded?.recipe.supplement.sha256).toBe(
|
||||
createHash("sha256").update(supplementJson).digest("hex"),
|
||||
);
|
||||
const supplement = (await loadLicenseSupplements())
|
||||
.get(LIBVIPS)
|
||||
?.find((candidate) => candidate.version === "1.3.4");
|
||||
expect(loaded?.recipe.package.contentDigest).toBe(supplement?.contentDigest);
|
||||
|
||||
// And those are the bytes sharp 0.35.5 installs.
|
||||
const root = await realpath(await installedPackageFile("sharp"));
|
||||
const target = NATIVE_TARGETS.get("linux-x64-glibc");
|
||||
if (target === undefined) throw new Error("target");
|
||||
const installed = (await nativeClosure([{ name: "sharp", root }], target)).find(
|
||||
(entry) => entry.name === LIBVIPS,
|
||||
);
|
||||
expect(installed && contentDigest(installed.files)).toBe(loaded?.recipe.package.contentDigest);
|
||||
|
||||
// Every component the supplement records inside the binary maps to a source component. The one
|
||||
// exception is the package's own entry, which carries the standard texts of its declared licence.
|
||||
const referenced = new Set(
|
||||
(loaded?.recipe.components ?? []).flatMap((component) =>
|
||||
component.licenseMaterial.map((material) => `${material.name} ${material.version}`),
|
||||
),
|
||||
);
|
||||
const unmapped = (supplement?.components ?? [])
|
||||
.map((component) => `${component.name} ${component.version}`)
|
||||
.filter((component) => !referenced.has(component));
|
||||
expect(unmapped).toEqual([`${LIBVIPS} 1.3.4`]);
|
||||
|
||||
const components = loaded?.recipe.components ?? [];
|
||||
const byRole = (role: string) =>
|
||||
components.filter((component) => component.role === role).length;
|
||||
expect({
|
||||
buildDefinition: byRole("build-definition"),
|
||||
buildInput: byRole("build-input"),
|
||||
compiled: byRole("compiled"),
|
||||
}).toEqual({
|
||||
buildDefinition: 1,
|
||||
buildInput: 206,
|
||||
compiled: 176,
|
||||
});
|
||||
// The five patches build/posix.sh applies, and the Rust standard library of the recorded nightly.
|
||||
expect(
|
||||
components.flatMap((component) => component.patches.map((patch) => patch.file)).sort(),
|
||||
).toEqual([
|
||||
"patches/glib-without-gregex.patch",
|
||||
"patches/librsvg-9106011db93d701728ac2c9da50c9ab2c1bb5dc6.patch",
|
||||
"patches/libultrahdr-pull-383.patch",
|
||||
"patches/libvips-cpp-soversion.patch",
|
||||
"patches/mozjpeg-f90668e0e4fb79c81e1f24a0ccc0e2090af761bf.patch",
|
||||
]);
|
||||
expect(
|
||||
components.find((component) => component.name === "Rust standard library")?.sources[0]?.file,
|
||||
).toBe("sources/rust/rust-src-nightly-2026-09-27.tar.xz");
|
||||
// gvdb ships inside glib's archive; proxy-libintl is never built for this target.
|
||||
expect(components.find((component) => component.name === "gvdb")?.includedIn).toEqual({
|
||||
component: "glib",
|
||||
path: "subprojects/gvdb",
|
||||
});
|
||||
expect(components.some((component) => component.name === "proxy-libintl")).toBe(false);
|
||||
expect(loaded?.recipe.exclusions.map((exclusion) => exclusion.name)).toContain("proxy-libintl");
|
||||
});
|
||||
});
|
||||
|
||||
async function packNative(): Promise<string> {
|
||||
const moduleDirectory = await nativeFixtureModule();
|
||||
const outDirectory = await scratchDirectory("ekklesia-native-out-");
|
||||
const result = await packModule({
|
||||
licenseFile: path.join(repositoryRoot, "LICENSE"),
|
||||
moduleDirectory,
|
||||
nodeRange: ">=24.11.0 <25",
|
||||
outDirectory,
|
||||
sourceRevision: SOURCE_REVISION,
|
||||
target: "linux-x64-glibc",
|
||||
});
|
||||
return result.archivePath;
|
||||
}
|
||||
|
||||
describe.runIf(onTargetHost && cache !== undefined && existsSync(cache ?? ""))(
|
||||
"the native proof's corresponding source, from the verified cache",
|
||||
() => {
|
||||
it("builds deterministically, is approved by the gate, and fails closed without it", {
|
||||
timeout: 600_000,
|
||||
}, async () => {
|
||||
const first = await packNative();
|
||||
const second = await packNative();
|
||||
expect((await readFile(first)).equals(await readFile(second))).toBe(true);
|
||||
|
||||
const outA = await scratchDirectory("ekklesia-source-a-");
|
||||
const outB = path.join(await scratchDirectory("ekklesia-unrelated-"), "nested", "out");
|
||||
const a = await buildCorrespondingSource({
|
||||
cacheDirectory: cache ?? "",
|
||||
outDirectory: outA,
|
||||
packageArchivePath: first,
|
||||
});
|
||||
const b = await buildCorrespondingSource({
|
||||
cacheDirectory: cache ?? "",
|
||||
outDirectory: outB,
|
||||
packageArchivePath: second,
|
||||
});
|
||||
expect(a.required).toBe(true);
|
||||
expect(a.sourceSha256).toBe(b.sourceSha256);
|
||||
expect(
|
||||
(await readFile(a.sourceArchivePath ?? "")).equals(
|
||||
await readFile(b.sourceArchivePath ?? ""),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(path.basename(a.sourceArchivePath ?? "")).toBe(
|
||||
"native-proof-0.1.0-linux-x64-glibc-corresponding-source.tar.gz",
|
||||
);
|
||||
|
||||
// What it holds: every recorded source, as bytes, and nothing unrecorded.
|
||||
const archive = await readFile(a.sourceArchivePath ?? "");
|
||||
const entries = readTarGz(archive);
|
||||
const top = "native-proof-0.1.0-linux-x64-glibc-corresponding-source";
|
||||
const files = entries
|
||||
.filter((entry) => !entry.directory)
|
||||
.map((entry) => entry.path.slice(top.length + 1));
|
||||
const manifest = JSON.parse(
|
||||
entries
|
||||
.find((entry) => entry.path === `${top}/corresponding-source.json`)
|
||||
?.data.toString("utf8") ?? "{}",
|
||||
);
|
||||
const dependency = manifest.dependencies[0];
|
||||
expect(dependency).toMatchObject({
|
||||
name: LIBVIPS,
|
||||
version: "1.3.4",
|
||||
binaries: ["lib/libvips-cpp.so.8.18.7"],
|
||||
});
|
||||
const base = `dependencies/${LIBVIPS}/1.3.4`;
|
||||
const sources = files.filter((file) => file.startsWith(`${base}/sources/`));
|
||||
expect(sources.filter((file) => file.startsWith(`${base}/sources/crates/`))).toHaveLength(
|
||||
353,
|
||||
);
|
||||
expect(
|
||||
sources.filter((file) => /\/sources\/[^/]+\.tar\.(?:gz|xz)$/u.test(file)),
|
||||
).toHaveLength(28);
|
||||
expect(files.filter((file) => file.startsWith(`${base}/patches/`))).toHaveLength(5);
|
||||
for (const required of [
|
||||
"BUILDING.md",
|
||||
"PROVENANCE.md",
|
||||
"recipe.json",
|
||||
"licenses/supplement.json",
|
||||
"build/prepare-offline.sh",
|
||||
"build/Cargo.lock",
|
||||
"sources/rust/rust-src-nightly-2026-09-27.tar.xz",
|
||||
]) {
|
||||
expect(files, required).toContain(`${base}/${required}`);
|
||||
}
|
||||
expect(new Set(manifest.files.map((file: { path: string }) => file.path))).toEqual(
|
||||
new Set(files.filter((file) => file !== "corresponding-source.json")),
|
||||
);
|
||||
|
||||
// The binary-source relationship, by digest.
|
||||
const metadata = JSON.parse(await readFile(a.metadataPath ?? "", "utf8"));
|
||||
const packageSha256 = createHash("sha256")
|
||||
.update(await readFile(first))
|
||||
.digest("hex");
|
||||
expect(metadata).toMatchObject({
|
||||
correspondingSource: {
|
||||
file: path.basename(a.sourceArchivePath ?? ""),
|
||||
sha256: a.sourceSha256,
|
||||
size: archive.length,
|
||||
},
|
||||
covered: [
|
||||
{
|
||||
contentDigest:
|
||||
"ekklesia-content-v1:sha256:2927fe11493461fa34847191d0b9b1c6441007c3e99f306e5708cb07fb0a5e59",
|
||||
name: LIBVIPS,
|
||||
version: "1.3.4",
|
||||
},
|
||||
],
|
||||
package: {
|
||||
file: path.basename(first),
|
||||
module: { id: "native-proof", version: "0.1.0" },
|
||||
sha256: packageSha256,
|
||||
target: "linux-x64-glibc",
|
||||
},
|
||||
});
|
||||
expect(metadata.notice).toContain("GNU LGPL and MPL");
|
||||
expect(metadata.notice).toContain(`SHA-256 ${a.sourceSha256}`);
|
||||
|
||||
// The gate approves exactly this, and fails closed when it is removed or corrupted.
|
||||
await expect(
|
||||
verifyRedistribution({
|
||||
metadataPath: a.metadataPath ?? "",
|
||||
packageArchivePath: first,
|
||||
sourceArchivePath: a.sourceArchivePath ?? "",
|
||||
}),
|
||||
).resolves.toMatchObject({ approved: true, correspondingSource: { sha256: a.sourceSha256 } });
|
||||
const missing = verifyRedistribution({
|
||||
metadataPath: a.metadataPath ?? "",
|
||||
packageArchivePath: first,
|
||||
});
|
||||
await expect(missing).rejects.toBeInstanceOf(CorrespondingSourceError);
|
||||
await expect(missing).rejects.toMatchObject({
|
||||
code: "CORRESPONDING_SOURCE_ARTIFACT_MISSING",
|
||||
});
|
||||
const corrupted = Buffer.from(archive);
|
||||
const flip = Math.floor(corrupted.length / 3);
|
||||
corrupted[flip] = (corrupted[flip] ?? 0) ^ 0x01;
|
||||
await writeFile(b.sourceArchivePath ?? "", corrupted);
|
||||
await expect(
|
||||
verifyRedistribution({
|
||||
metadataPath: b.metadataPath ?? "",
|
||||
packageArchivePath: second,
|
||||
sourceArchivePath: b.sourceArchivePath ?? "",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: expect.stringMatching(
|
||||
/^CORRESPONDING_SOURCE_(?:ARTIFACT_INVALID|MANIFEST_MISMATCH|METADATA_MISMATCH)$/u,
|
||||
),
|
||||
});
|
||||
await rm(b.sourceArchivePath ?? "");
|
||||
await expect(
|
||||
verifyRedistribution({
|
||||
metadataPath: b.metadataPath ?? "",
|
||||
packageArchivePath: second,
|
||||
sourceArchivePath: b.sourceArchivePath ?? "",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CORRESPONDING_SOURCE_ARTIFACT_MISSING" });
|
||||
});
|
||||
|
||||
it("leaves the binary package itself unchanged: it verifies, and sharp runs from it", {
|
||||
timeout: 300_000,
|
||||
}, async () => {
|
||||
const archivePath = await packNative();
|
||||
const archive = await readFile(archivePath);
|
||||
const extractTo = path.join(
|
||||
await scratchDirectory("ekklesia-native-installed-"),
|
||||
"native-proof",
|
||||
);
|
||||
await expect(
|
||||
verifyModulePackage({
|
||||
archivePath,
|
||||
expectedSha256: createHash("sha256").update(archive).digest("hex"),
|
||||
extractTo,
|
||||
platform: {
|
||||
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
||||
moduleSdkVersion: "0.2.1",
|
||||
platformVersion: "0.2.1",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({ moduleId: "native-proof", target: "linux-x64-glibc" });
|
||||
// The source archive never travels inside the package.
|
||||
expect(readTarGz(archive).some((entry) => /corresponding-source/u.test(entry.path))).toBe(
|
||||
false,
|
||||
);
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`const m = await import(${JSON.stringify(`file://${extractTo}/server/index.js`)}); process.stdout.write(JSON.stringify(await m.proof()));`,
|
||||
],
|
||||
{ cwd: tmpdir(), env: {} },
|
||||
);
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
pixels: [255, 0, 0, 255, 0, 0, 255, 0, 0, 255, 0, 0],
|
||||
png: { format: "png", height: 3, width: 5 },
|
||||
resized: { channels: 3, height: 2, width: 2 },
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,774 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { gunzipSync, gzipSync } from "node:zlib";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
acquireCorrespondingSources,
|
||||
buildCorrespondingSource,
|
||||
CorrespondingSourceError,
|
||||
type CorrespondingSourceErrorCode,
|
||||
hasSourceObligations,
|
||||
loadSourceRecipes,
|
||||
verifyRedistribution,
|
||||
} from "../src/corresponding-source.js";
|
||||
import { contentDigest } from "../src/supplements.js";
|
||||
import { deterministicTarGz, readTarGz } from "../src/tar.js";
|
||||
import { cleanupFixtures, scratchDirectory } from "./support/fixture-module.js";
|
||||
|
||||
/**
|
||||
* Corresponding-source artifacts and the public-distribution gate (ADR 0055), on small synthetic
|
||||
* inputs: a fake LGPL native package, its reviewed supplement, its source recipe, and a source
|
||||
* cache. The real libvips proof is `corresponding-source-native.test.ts`.
|
||||
*/
|
||||
|
||||
afterEach(cleanupFixtures);
|
||||
|
||||
function sha256(data: string | Uint8Array): string {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
const PACKAGE_FILE = "example-0.1.0-linux-x64-glibc.tar.gz";
|
||||
const SOURCE_FILE = "example-0.1.0-linux-x64-glibc-corresponding-source.tar.gz";
|
||||
const METADATA_FILE = "example-0.1.0-linux-x64-glibc.redistribution.json";
|
||||
const TOP = "example-0.1.0-linux-x64-glibc-corresponding-source";
|
||||
|
||||
const SOURCES: Readonly<Record<string, string>> = {
|
||||
"sources/fake-build-1.0.tar.gz": "build definitions for libfake\n",
|
||||
"sources/fakelib-2.0.tar.gz": "fakelib 2.0 source\n",
|
||||
"patches/fakelib-fix.patch": "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n",
|
||||
"sources/helperlib-1.1.tar.gz": "helperlib 1.1 source\n",
|
||||
"sources/codegen-0.3.crate": "codegen 0.3 build-only source\n",
|
||||
};
|
||||
|
||||
interface FixtureOptions {
|
||||
readonly version?: string;
|
||||
readonly libraryBytes?: string;
|
||||
readonly license?: string;
|
||||
readonly notices?: string;
|
||||
readonly recipe?: (recipe: Record<string, unknown>) => void;
|
||||
readonly withRecipe?: boolean;
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
readonly root: string;
|
||||
readonly packagePath: string;
|
||||
readonly cache: string;
|
||||
readonly recipesDirectory: string;
|
||||
readonly supplementsDirectory: string;
|
||||
}
|
||||
|
||||
function noticeEntry(name: string, version: string, licence: string, uses: string): string {
|
||||
const rule = "-".repeat(80);
|
||||
return `${rule}\n${name} ${version}\nLicense: ${licence}\nUsed in: ${uses}\n${rule}\n\n[LICENSE]\n\ntext\n`;
|
||||
}
|
||||
|
||||
/** Writes a fake native package into a Module Package archive, its supplement, recipe, and cache. */
|
||||
async function fixture(options: FixtureOptions = {}): Promise<Fixture> {
|
||||
const version = options.version ?? "1.0.0";
|
||||
const license = options.license ?? "LGPL-2.1-or-later";
|
||||
const root = await scratchDirectory("ekklesia-corresponding-");
|
||||
const nativeFiles = new Map<string, Buffer>([
|
||||
["lib/libfake.so.1", Buffer.from(options.libraryBytes ?? `\u007fELF fake library ${version}`)],
|
||||
["package.json", Buffer.from(JSON.stringify({ license, name: "fake-native", version }))],
|
||||
]);
|
||||
const digest = contentDigest(nativeFiles);
|
||||
const packageFiles = new Map<string, Buffer>([
|
||||
[
|
||||
"ekklesia.package.json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
module: { id: "example", version: "0.1.0" },
|
||||
packageFormat: 1,
|
||||
target: "linux-x64-glibc",
|
||||
}),
|
||||
),
|
||||
],
|
||||
[
|
||||
"THIRD_PARTY_NOTICES",
|
||||
Buffer.from(options.notices ?? noticeEntry("fake-native", version, license, "node_modules")),
|
||||
],
|
||||
...[...nativeFiles].map(([file, data]) => [`node_modules/fake-native/${file}`, data] as const),
|
||||
]);
|
||||
const packageDirectory = path.join(root, "package");
|
||||
await mkdir(packageDirectory);
|
||||
const packagePath = path.join(packageDirectory, PACKAGE_FILE);
|
||||
await writeFile(packagePath, deterministicTarGz("example", packageFiles));
|
||||
|
||||
// The reviewed supplement for exactly these package bytes.
|
||||
const supplementsDirectory = path.join(root, "supplements");
|
||||
const supplementDirectory = path.join(supplementsDirectory, "fake-native", version);
|
||||
await mkdir(path.join(supplementDirectory, "texts"), { recursive: true });
|
||||
const material = async (content: string) => {
|
||||
await writeFile(path.join(supplementDirectory, "texts", `${sha256(content)}.txt`), content);
|
||||
return `texts/${sha256(content)}.txt`;
|
||||
};
|
||||
const supplement = {
|
||||
supplementFormat: 1,
|
||||
package: { name: "fake-native", version, license, contentDigest: digest },
|
||||
statement: "Covers the libraries built into lib/libfake.so.1.",
|
||||
components: [
|
||||
{
|
||||
name: "fakelib",
|
||||
version: "2.0",
|
||||
license: "LGPL-2.1-or-later",
|
||||
source: "https://example.org/fakelib-2.0.tar.gz",
|
||||
material: [
|
||||
{ title: "COPYING", text: await material("GNU LESSER GENERAL PUBLIC LICENSE (fake)\n") },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "helperlib",
|
||||
version: "1.1",
|
||||
license: "MIT",
|
||||
source: "https://example.org/helperlib-1.1.tar.gz",
|
||||
material: [{ title: "LICENSE", text: await material("MIT License (fake)\n") }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const supplementJson = `${JSON.stringify(supplement, null, 2)}\n`;
|
||||
await writeFile(path.join(supplementDirectory, "supplement.json"), supplementJson);
|
||||
await writeFile(path.join(supplementDirectory, "PROVENANCE.md"), "# How this was assembled\n");
|
||||
|
||||
// The recipe, and a cache holding the recorded bytes.
|
||||
const recipesDirectory = path.join(root, "recipes");
|
||||
const cache = path.join(root, "cache");
|
||||
await mkdir(path.join(cache, "sha256"), { recursive: true });
|
||||
const file = async (relative: string) => {
|
||||
const content = SOURCES[relative] ?? "";
|
||||
await writeFile(path.join(cache, "sha256", sha256(content)), content);
|
||||
return {
|
||||
file: relative,
|
||||
origin: `https://example.org/${path.basename(relative)}`,
|
||||
sha256: sha256(content),
|
||||
size: Buffer.byteLength(content),
|
||||
};
|
||||
};
|
||||
const building = "# Rebuilding libfake\n\nRun `make` in fake-build.\n";
|
||||
const recipe: Record<string, unknown> = {
|
||||
recipeFormat: 1,
|
||||
package: {
|
||||
name: "fake-native",
|
||||
version,
|
||||
license,
|
||||
contentDigest: digest,
|
||||
target: "linux-x64-glibc",
|
||||
binaries: ["lib/libfake.so.1"],
|
||||
},
|
||||
supplement: { version, sha256: sha256(supplementJson) },
|
||||
documents: [
|
||||
{ path: "BUILDING.md", sha256: sha256(building), size: Buffer.byteLength(building) },
|
||||
],
|
||||
components: [
|
||||
{
|
||||
name: "fake-build",
|
||||
version: "1.0",
|
||||
role: "build-definition",
|
||||
license: "MIT",
|
||||
relationship: "The build definitions.",
|
||||
licenseMaterial: [],
|
||||
sources: [await file("sources/fake-build-1.0.tar.gz")],
|
||||
patches: [],
|
||||
},
|
||||
{
|
||||
name: "fakelib",
|
||||
version: "2.0",
|
||||
role: "compiled",
|
||||
license: "LGPL-2.1-or-later",
|
||||
relationship: "Compiled into lib/libfake.so.1.",
|
||||
licenseMaterial: [{ name: "fakelib", version: "2.0" }],
|
||||
sources: [await file("sources/fakelib-2.0.tar.gz")],
|
||||
patches: [await file("patches/fakelib-fix.patch")],
|
||||
},
|
||||
{
|
||||
name: "helperlib",
|
||||
version: "1.1",
|
||||
role: "compiled",
|
||||
license: "MIT",
|
||||
relationship: "Compiled into lib/libfake.so.1.",
|
||||
licenseMaterial: [{ name: "helperlib", version: "1.1" }],
|
||||
sources: [await file("sources/helperlib-1.1.tar.gz")],
|
||||
patches: [],
|
||||
},
|
||||
{
|
||||
name: "codegen",
|
||||
version: "0.3",
|
||||
role: "build-input",
|
||||
license: "MIT",
|
||||
relationship: "Needed only to build.",
|
||||
licenseMaterial: [],
|
||||
sources: [await file("sources/codegen-0.3.crate")],
|
||||
patches: [],
|
||||
},
|
||||
],
|
||||
exclusions: [{ name: "testlib", reason: "Used only by tests." }],
|
||||
};
|
||||
options.recipe?.(recipe);
|
||||
if (options.withRecipe !== false) {
|
||||
const recipeDirectory = path.join(recipesDirectory, "fake-native", version);
|
||||
await mkdir(recipeDirectory, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(recipeDirectory, "recipe.json"),
|
||||
`${JSON.stringify(recipe, null, 2)}\n`,
|
||||
);
|
||||
await writeFile(path.join(recipeDirectory, "BUILDING.md"), building);
|
||||
}
|
||||
return { cache, packagePath, recipesDirectory, root, supplementsDirectory };
|
||||
}
|
||||
|
||||
async function build(fx: Fixture, out = path.join(fx.root, "out")) {
|
||||
return buildCorrespondingSource({
|
||||
cacheDirectory: fx.cache,
|
||||
outDirectory: out,
|
||||
packageArchivePath: fx.packagePath,
|
||||
recipesDirectory: fx.recipesDirectory,
|
||||
supplementsDirectory: fx.supplementsDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
function verify(fx: Fixture, source?: string, metadata?: string) {
|
||||
return verifyRedistribution({
|
||||
packageArchivePath: fx.packagePath,
|
||||
recipesDirectory: fx.recipesDirectory,
|
||||
supplementsDirectory: fx.supplementsDirectory,
|
||||
...(source === undefined ? {} : { sourceArchivePath: source }),
|
||||
...(metadata === undefined ? {} : { metadataPath: metadata }),
|
||||
});
|
||||
}
|
||||
|
||||
async function rejects(promise: Promise<unknown>, code: CorrespondingSourceErrorCode) {
|
||||
const error = await promise.then(
|
||||
() => undefined,
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toBeInstanceOf(CorrespondingSourceError);
|
||||
expect((error as CorrespondingSourceError).code).toBe(code);
|
||||
return error as CorrespondingSourceError;
|
||||
}
|
||||
|
||||
/** A built release, ready to tamper with: its source archive's files, and a way to write a changed
|
||||
* archive (and metadata naming it) where the gate will look. */
|
||||
async function release() {
|
||||
const fx = await fixture();
|
||||
const out = path.join(fx.root, "out");
|
||||
await build(fx, out);
|
||||
const sourcePath = path.join(out, SOURCE_FILE);
|
||||
const metadataPath = path.join(out, METADATA_FILE);
|
||||
const files = new Map<string, Buffer>();
|
||||
for (const entry of readTarGz(await readFile(sourcePath))) {
|
||||
if (!entry.directory) files.set(entry.path.slice(TOP.length + 1), entry.data);
|
||||
}
|
||||
/** Writes `changed` as the archive and keeps the metadata consistent with its bytes, so only the
|
||||
* change itself can fail the gate. */
|
||||
const write = async (changed: Map<string, Buffer>, archive?: Buffer) => {
|
||||
const bytes = archive ?? deterministicTarGz(TOP, changed);
|
||||
await writeFile(sourcePath, bytes);
|
||||
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
||||
const digest = sha256(bytes);
|
||||
metadata.correspondingSource.sha256 = digest;
|
||||
metadata.correspondingSource.size = bytes.length;
|
||||
metadata.correspondingSource.manifestSha256 = sha256(
|
||||
changed.get("corresponding-source.json") ?? "",
|
||||
);
|
||||
metadata.notice = metadata.notice.replace(/SHA-256 [0-9a-f]{64}/u, `SHA-256 ${digest}`);
|
||||
await writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
||||
};
|
||||
/** Rewrites the manifest's file list to match `changed`, as a careful forger would. */
|
||||
const relist = (changed: Map<string, Buffer>) => {
|
||||
const manifest = JSON.parse(changed.get("corresponding-source.json")?.toString("utf8") ?? "{}");
|
||||
manifest.files = [...changed]
|
||||
.filter(([file]) => file !== "corresponding-source.json")
|
||||
.map(([file, data]) => ({ path: file, size: data.length, sha256: sha256(data) }))
|
||||
.sort((left: { path: string }, right: { path: string }) => (left.path < right.path ? -1 : 1));
|
||||
changed.set("corresponding-source.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
|
||||
return manifest;
|
||||
};
|
||||
return { files, fx, metadataPath, out, relist, sourcePath, write };
|
||||
}
|
||||
|
||||
describe("building the corresponding-source artifact", () => {
|
||||
it("writes a deterministic archive, its digest, the redistribution metadata, and the release notice", async () => {
|
||||
const fx = await fixture();
|
||||
const first = await build(fx, path.join(fx.root, "out-a"));
|
||||
// A second build into an unrelated directory, from a moved cache and package copy.
|
||||
const second = await build(
|
||||
fx,
|
||||
path.join(await scratchDirectory("ekklesia-elsewhere-"), "out-b"),
|
||||
);
|
||||
expect(first.required).toBe(true);
|
||||
expect(first.sourceSha256).toBe(second.sourceSha256);
|
||||
expect(await readFile(first.sourceArchivePath ?? "")).toEqual(
|
||||
await readFile(second.sourceArchivePath ?? ""),
|
||||
);
|
||||
expect(await readFile(`${first.sourceArchivePath}.sha256`, "utf8")).toBe(
|
||||
`${first.sourceSha256} ${SOURCE_FILE}\n`,
|
||||
);
|
||||
|
||||
const entries = readTarGz(await readFile(first.sourceArchivePath ?? ""));
|
||||
const files = entries
|
||||
.filter((entry) => !entry.directory)
|
||||
.map((entry) => entry.path.slice(TOP.length + 1));
|
||||
expect(files).toEqual([
|
||||
"README.md",
|
||||
"SHA256SUMS",
|
||||
"corresponding-source.json",
|
||||
"dependencies/fake-native/1.0.0/BUILDING.md",
|
||||
"dependencies/fake-native/1.0.0/PROVENANCE.md",
|
||||
"dependencies/fake-native/1.0.0/licenses/supplement.json",
|
||||
expect.stringMatching(
|
||||
/^dependencies\/fake-native\/1\.0\.0\/licenses\/texts\/[0-9a-f]{64}\.txt$/u,
|
||||
),
|
||||
expect.stringMatching(
|
||||
/^dependencies\/fake-native\/1\.0\.0\/licenses\/texts\/[0-9a-f]{64}\.txt$/u,
|
||||
),
|
||||
"dependencies/fake-native/1.0.0/patches/fakelib-fix.patch",
|
||||
"dependencies/fake-native/1.0.0/recipe.json",
|
||||
"dependencies/fake-native/1.0.0/sources/codegen-0.3.crate",
|
||||
"dependencies/fake-native/1.0.0/sources/fake-build-1.0.tar.gz",
|
||||
"dependencies/fake-native/1.0.0/sources/fakelib-2.0.tar.gz",
|
||||
"dependencies/fake-native/1.0.0/sources/helperlib-1.1.tar.gz",
|
||||
]);
|
||||
// The actual bytes, not references to them.
|
||||
const patch = entries.find((entry) => entry.path.endsWith("patches/fakelib-fix.patch"));
|
||||
expect(patch?.data.toString("utf8")).toBe(SOURCES["patches/fakelib-fix.patch"]);
|
||||
|
||||
const manifest = JSON.parse(
|
||||
entries
|
||||
.find((entry) => entry.path.endsWith("/corresponding-source.json"))
|
||||
?.data.toString("utf8") ?? "{}",
|
||||
);
|
||||
expect(manifest.module).toEqual({ id: "example", target: "linux-x64-glibc", version: "0.1.0" });
|
||||
expect(manifest.dependencies[0]).toMatchObject({
|
||||
binaries: ["lib/libfake.so.1"],
|
||||
exclusions: [{ name: "testlib", reason: "Used only by tests." }],
|
||||
name: "fake-native",
|
||||
version: "1.0.0",
|
||||
});
|
||||
expect(
|
||||
manifest.dependencies[0].components.map((component: { role: string }) => component.role),
|
||||
).toEqual(["build-definition", "compiled", "compiled", "build-input"]);
|
||||
|
||||
const metadata = JSON.parse(await readFile(first.metadataPath ?? "", "utf8"));
|
||||
expect(metadata).toMatchObject({
|
||||
correspondingSource: {
|
||||
file: SOURCE_FILE,
|
||||
sha256: first.sourceSha256,
|
||||
manifestSha256: first.manifestSha256,
|
||||
},
|
||||
covered: [{ name: "fake-native", version: "1.0.0" }],
|
||||
package: { file: PACKAGE_FILE, sha256: sha256(await readFile(fx.packagePath)) },
|
||||
});
|
||||
const notice = await readFile(first.noticePath ?? "", "utf8");
|
||||
expect(notice).toBe(metadata.notice);
|
||||
expect(notice).toContain(
|
||||
"This release includes third-party software covered by licenses including the GNU LGPL",
|
||||
);
|
||||
expect(notice).toContain(
|
||||
"Corresponding source and build/relink materials for the covered native dependencies",
|
||||
);
|
||||
expect(notice).toContain(SOURCE_FILE);
|
||||
expect(notice).toContain(`SHA-256 ${first.sourceSha256}`);
|
||||
// Nothing machine-specific in what the build generated.
|
||||
for (const generated of ["README.md", "SHA256SUMS", "corresponding-source.json"]) {
|
||||
const content =
|
||||
entries.find((entry) => entry.path === `${TOP}/${generated}`)?.data.toString("utf8") ?? "";
|
||||
expect(content).not.toContain(fx.root);
|
||||
expect(content).not.toContain(fx.cache);
|
||||
}
|
||||
expect(JSON.stringify(metadata)).not.toContain(fx.root);
|
||||
});
|
||||
|
||||
it("is approved by the gate exactly as built", async () => {
|
||||
const { fx, metadataPath, sourcePath } = await release();
|
||||
await expect(verify(fx, sourcePath, metadataPath)).resolves.toMatchObject({
|
||||
approved: true,
|
||||
correspondingSource: { file: SOURCE_FILE },
|
||||
covered: [{ name: "fake-native", version: "1.0.0" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to build from cached bytes that differ from the recipe, and writes nothing", async () => {
|
||||
const fx = await fixture();
|
||||
await writeFile(
|
||||
path.join(fx.cache, "sha256", sha256(SOURCES["patches/fakelib-fix.patch"] ?? "")),
|
||||
"changed patch\n",
|
||||
);
|
||||
await rejects(build(fx), "CORRESPONDING_SOURCE_SOURCE_MISMATCH");
|
||||
await rm(path.join(fx.cache, "sha256", sha256(SOURCES["sources/fakelib-2.0.tar.gz"] ?? "")));
|
||||
await expect(readFile(path.join(fx.root, "out", SOURCE_FILE))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("refuses a missing cached file, and never overwrites an existing release", async () => {
|
||||
const fx = await fixture();
|
||||
await rm(path.join(fx.cache, "sha256", sha256(SOURCES["sources/helperlib-1.1.tar.gz"] ?? "")));
|
||||
await rejects(build(fx), "CORRESPONDING_SOURCE_SOURCE_MISSING");
|
||||
const fresh = await fixture();
|
||||
await build(fresh);
|
||||
await rejects(build(fresh), "CORRESPONDING_SOURCE_OUTPUT_EXISTS");
|
||||
});
|
||||
|
||||
it("builds nothing for a package with no covered native code, and the gate approves it without an artifact", async () => {
|
||||
const fx = await fixture({ license: "MIT", withRecipe: false });
|
||||
// The fixture's supplement records an LGPL component, so remove it: a package whose own files and
|
||||
// licence carry no source obligation needs no corresponding source.
|
||||
await rm(fx.supplementsDirectory, { force: true, recursive: true });
|
||||
await expect(build(fx)).resolves.toEqual({ required: false });
|
||||
await expect(verify(fx)).resolves.toMatchObject({ approved: true, covered: [] });
|
||||
});
|
||||
|
||||
it("treats a dependency as covered when its reviewed supplement records a copyleft component", async () => {
|
||||
// Declared MIT, but the supplement says an LGPL library is built into it.
|
||||
const fx = await fixture({ license: "MIT", withRecipe: false });
|
||||
// The supplement's declared licence must match the package's for the supplement to apply.
|
||||
const supplementPath = path.join(
|
||||
fx.supplementsDirectory,
|
||||
"fake-native",
|
||||
"1.0.0",
|
||||
"supplement.json",
|
||||
);
|
||||
const supplement = JSON.parse(await readFile(supplementPath, "utf8"));
|
||||
expect(supplement.package.license).toBe("MIT");
|
||||
await rejects(build(fx), "CORRESPONDING_SOURCE_RECIPE_MISSING");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the public-distribution gate fails closed", () => {
|
||||
it("without the corresponding-source archive, or without the redistribution metadata", async () => {
|
||||
const { fx, metadataPath, sourcePath } = await release();
|
||||
await rejects(verify(fx), "CORRESPONDING_SOURCE_ARTIFACT_MISSING");
|
||||
await rm(sourcePath);
|
||||
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_ARTIFACT_MISSING");
|
||||
const second = await release();
|
||||
await rejects(verify(second.fx, second.sourcePath), "CORRESPONDING_SOURCE_METADATA_MISMATCH");
|
||||
});
|
||||
|
||||
it("when the metadata names a different source digest, or the archive's bytes are corrupted", async () => {
|
||||
const { fx, metadataPath, sourcePath } = await release();
|
||||
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
||||
metadata.correspondingSource.sha256 = "0".repeat(64);
|
||||
await writeFile(metadataPath, JSON.stringify(metadata));
|
||||
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_METADATA_MISMATCH");
|
||||
|
||||
const corrupt = await release();
|
||||
const bytes = await readFile(corrupt.sourcePath);
|
||||
const middle = Math.floor(bytes.length / 2);
|
||||
bytes[middle] = (bytes[middle] ?? 0) ^ 0xff;
|
||||
await writeFile(corrupt.sourcePath, bytes);
|
||||
await rejects(
|
||||
verify(corrupt.fx, corrupt.sourcePath, corrupt.metadataPath),
|
||||
"CORRESPONDING_SOURCE_ARTIFACT_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("when the release notice does not name the exact artifact", async () => {
|
||||
const { fx, metadataPath, sourcePath } = await release();
|
||||
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
||||
metadata.notice = metadata.notice.replace(SOURCE_FILE, "some-other-source.tar.gz");
|
||||
await writeFile(metadataPath, JSON.stringify(metadata));
|
||||
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_METADATA_MISMATCH");
|
||||
});
|
||||
|
||||
it("when the native package's bytes are not those the recipe was prepared for", async () => {
|
||||
const built = await release();
|
||||
// The same name and version, different library bytes: a different content digest.
|
||||
const other = await fixture({ libraryBytes: "\u007fELF a different build" });
|
||||
await writeFile(built.fx.packagePath, await readFile(other.packagePath));
|
||||
await rejects(
|
||||
verify(built.fx, built.sourcePath, built.metadataPath),
|
||||
"CORRESPONDING_SOURCE_RECIPE_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("when a source component's version differs from the recipe", async () => {
|
||||
const { files, fx, metadataPath, relist, sourcePath, write } = await release();
|
||||
const manifest = JSON.parse(files.get("corresponding-source.json")?.toString("utf8") ?? "{}");
|
||||
manifest.dependencies[0].components[1].version = "2.1";
|
||||
files.set("corresponding-source.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
|
||||
relist(files);
|
||||
await write(files);
|
||||
const error = await rejects(
|
||||
verify(fx, sourcePath, metadataPath),
|
||||
"CORRESPONDING_SOURCE_MANIFEST_MISMATCH",
|
||||
);
|
||||
expect(error.subject).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("when a required source component is missing", async () => {
|
||||
const { files, fx, metadataPath, relist, sourcePath, write } = await release();
|
||||
const manifest = JSON.parse(files.get("corresponding-source.json")?.toString("utf8") ?? "{}");
|
||||
manifest.dependencies[0].components.splice(2, 1);
|
||||
files.set("corresponding-source.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
|
||||
files.delete("dependencies/fake-native/1.0.0/sources/helperlib-1.1.tar.gz");
|
||||
relist(files);
|
||||
await write(files);
|
||||
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_MANIFEST_MISMATCH");
|
||||
});
|
||||
|
||||
it("when a patch's bytes changed, even with the file list rewritten to match", async () => {
|
||||
const { files, fx, metadataPath, relist, sourcePath, write } = await release();
|
||||
files.set(
|
||||
"dependencies/fake-native/1.0.0/patches/fakelib-fix.patch",
|
||||
Buffer.from("a different patch\n"),
|
||||
);
|
||||
relist(files);
|
||||
await write(files);
|
||||
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_MANIFEST_MISMATCH");
|
||||
});
|
||||
|
||||
it("when the manifest records a file the archive does not hold", async () => {
|
||||
const { files, fx, metadataPath, sourcePath, write } = await release();
|
||||
files.delete("dependencies/fake-native/1.0.0/sources/fakelib-2.0.tar.gz");
|
||||
await write(files);
|
||||
const error = await rejects(
|
||||
verify(fx, sourcePath, metadataPath),
|
||||
"CORRESPONDING_SOURCE_MANIFEST_MISMATCH",
|
||||
);
|
||||
expect(error.subject).toBe("dependencies/fake-native/1.0.0/sources/fakelib-2.0.tar.gz");
|
||||
});
|
||||
|
||||
it("when the archive holds source material its manifest does not record", async () => {
|
||||
const { files, fx, metadataPath, sourcePath, write } = await release();
|
||||
files.set(
|
||||
"dependencies/fake-native/1.0.0/sources/unreviewed.tar.gz",
|
||||
Buffer.from("not recorded\n"),
|
||||
);
|
||||
await write(files);
|
||||
const error = await rejects(
|
||||
verify(fx, sourcePath, metadataPath),
|
||||
"CORRESPONDING_SOURCE_UNRECORDED_FILE",
|
||||
);
|
||||
expect(error.subject).toBe("dependencies/fake-native/1.0.0/sources/unreviewed.tar.gz");
|
||||
});
|
||||
|
||||
it("when a generated file names a machine-specific path", async () => {
|
||||
const { files, fx, metadataPath, relist, sourcePath, write } = await release();
|
||||
files.set("README.md", Buffer.from("Built from /home/builder/cache/sha256.\n"));
|
||||
relist(files);
|
||||
await write(files);
|
||||
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_LOCAL_PATH");
|
||||
});
|
||||
|
||||
it("when the archive's entries are not in the deterministic order", async () => {
|
||||
const { files, fx, metadataPath, sourcePath, write } = await release();
|
||||
// The same entries and bytes, with the last two file entries swapped.
|
||||
const raw = gunzipSync(await readFile(sourcePath));
|
||||
const chunks: Buffer[] = [];
|
||||
let offset = 0;
|
||||
while (offset < raw.length && !raw.subarray(offset, offset + 512).every((byte) => byte === 0)) {
|
||||
const size = Number.parseInt(
|
||||
raw
|
||||
.subarray(offset + 124, offset + 136)
|
||||
.toString("latin1")
|
||||
.replace(/\0.*$/u, "")
|
||||
.trim() || "0",
|
||||
8,
|
||||
);
|
||||
const length = 512 + Math.ceil(size / 512) * 512;
|
||||
chunks.push(raw.subarray(offset, offset + length));
|
||||
offset += length;
|
||||
}
|
||||
const last = chunks.length - 1;
|
||||
[chunks[last - 1], chunks[last]] = [chunks[last] as Buffer, chunks[last - 1] as Buffer];
|
||||
await write(files, gzipSync(Buffer.concat([...chunks, Buffer.alloc(1024)]), { level: 9 }));
|
||||
await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_ARTIFACT_INVALID");
|
||||
});
|
||||
|
||||
it("when the artifact was built for another version of the native dependency", async () => {
|
||||
// The 1.3.3-for-1.3.4 case: an artifact for fake-native 1.0.0 beside a package with 1.0.1.
|
||||
const old = await release();
|
||||
const current = await fixture({ version: "1.0.1" });
|
||||
await mkdir(path.join(current.recipesDirectory, "fake-native", "1.0.0"), { recursive: true });
|
||||
for (const file of ["recipe.json", "BUILDING.md"]) {
|
||||
await writeFile(
|
||||
path.join(current.recipesDirectory, "fake-native", "1.0.0", file),
|
||||
await readFile(path.join(old.fx.recipesDirectory, "fake-native", "1.0.0", file)),
|
||||
);
|
||||
}
|
||||
await rejects(
|
||||
verify(current, old.sourcePath, old.metadataPath),
|
||||
"CORRESPONDING_SOURCE_MANIFEST_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("when the package bundles copyleft code, which this mechanism does not cover", async () => {
|
||||
const notices =
|
||||
noticeEntry("fake-native", "1.0.0", "LGPL-2.1-or-later", "node_modules") +
|
||||
noticeEntry("bundled-lgpl", "3.0.0", "LGPL-3.0-only", "backend");
|
||||
const fx = await fixture({ notices });
|
||||
await rejects(build(fx), "CORRESPONDING_SOURCE_BUNDLED_COPYLEFT");
|
||||
await rejects(verify(fx), "CORRESPONDING_SOURCE_BUNDLED_COPYLEFT");
|
||||
});
|
||||
|
||||
it("when a covered dependency has no recipe, or its recipe was prepared against another supplement", async () => {
|
||||
await rejects(
|
||||
build(await fixture({ withRecipe: false })),
|
||||
"CORRESPONDING_SOURCE_RECIPE_MISSING",
|
||||
);
|
||||
const fx = await fixture({
|
||||
recipe: (recipe) => {
|
||||
(recipe.supplement as { sha256: string }).sha256 = "f".repeat(64);
|
||||
},
|
||||
});
|
||||
await rejects(build(fx), "CORRESPONDING_SOURCE_RECIPE_MISMATCH");
|
||||
const unknownMaterial = await fixture({
|
||||
recipe: (recipe) => {
|
||||
const components = recipe.components as {
|
||||
licenseMaterial: { name: string; version: string }[];
|
||||
}[];
|
||||
(components[1] as { licenseMaterial: unknown[] }).licenseMaterial = [
|
||||
{ name: "fakelib", version: "9.9" },
|
||||
];
|
||||
},
|
||||
});
|
||||
await rejects(build(unknownMaterial), "CORRESPONDING_SOURCE_RECIPE_MISMATCH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("source recipes are reviewed inputs", () => {
|
||||
async function loadWith(change: (recipe: Record<string, unknown>) => void) {
|
||||
const fx = await fixture({ recipe: change });
|
||||
return loadSourceRecipes(fx.recipesDirectory);
|
||||
}
|
||||
|
||||
it("refuses an absolute or climbing source path, a non-https origin, or a compiled component without licence material", async () => {
|
||||
const component = (recipe: Record<string, unknown>, index: number) =>
|
||||
(recipe.components as Record<string, unknown>[])[index] as Record<string, unknown>;
|
||||
const first = (recipe: Record<string, unknown>) =>
|
||||
(component(recipe, 1).sources as Record<string, unknown>[])[0] as Record<string, unknown>;
|
||||
for (const change of [
|
||||
(recipe: Record<string, unknown>) => {
|
||||
first(recipe).file = "/home/builder/fakelib-2.0.tar.gz";
|
||||
},
|
||||
(recipe: Record<string, unknown>) => {
|
||||
first(recipe).file = "sources/../../fakelib-2.0.tar.gz";
|
||||
},
|
||||
(recipe: Record<string, unknown>) => {
|
||||
first(recipe).origin = "http://example.org/fakelib-2.0.tar.gz";
|
||||
},
|
||||
(recipe: Record<string, unknown>) => {
|
||||
component(recipe, 1).licenseMaterial = [];
|
||||
},
|
||||
(recipe: Record<string, unknown>) => {
|
||||
component(recipe, 2).version = "2.0";
|
||||
component(recipe, 2).name = "fakelib";
|
||||
},
|
||||
]) {
|
||||
await rejects(loadWith(change), "CORRESPONDING_SOURCE_RECIPE_INVALID");
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a document whose bytes changed, and an unlisted file beside the recipe", async () => {
|
||||
const fx = await fixture();
|
||||
const directory = path.join(fx.recipesDirectory, "fake-native", "1.0.0");
|
||||
await writeFile(path.join(directory, "BUILDING.md"), "edited without review\n");
|
||||
await rejects(loadSourceRecipes(fx.recipesDirectory), "CORRESPONDING_SOURCE_RECIPE_INVALID");
|
||||
const second = await fixture();
|
||||
await writeFile(
|
||||
path.join(second.recipesDirectory, "fake-native", "1.0.0", "notes.txt"),
|
||||
"stray\n",
|
||||
);
|
||||
await rejects(
|
||||
loadSourceRecipes(second.recipesDirectory),
|
||||
"CORRESPONDING_SOURCE_RECIPE_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes licences with source obligations, by SPDX identifier or by upstream name", () => {
|
||||
for (const licence of [
|
||||
"LGPL-3.0-or-later",
|
||||
"MPL-2.0",
|
||||
"GPL-2.0-only",
|
||||
"Mozilla Public License 1.1",
|
||||
"MIT AND LGPL-2.1-or-later",
|
||||
"EPL-2.0",
|
||||
]) {
|
||||
expect(hasSourceObligations(licence), licence).toBe(true);
|
||||
}
|
||||
for (const licence of [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"Zlib",
|
||||
"MIT OR Apache-2.0",
|
||||
]) {
|
||||
expect(hasSourceObligations(licence), licence).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("acquisition is separate, verified, and the only network step", () => {
|
||||
it("fetches each recorded file once, verifies it, and refuses a response that differs", async () => {
|
||||
const fx = await fixture();
|
||||
const fetched: string[] = [];
|
||||
const served = new Map(
|
||||
Object.entries(SOURCES).map(([file, content]) => [
|
||||
`https://example.org/${path.basename(file)}`,
|
||||
content,
|
||||
]),
|
||||
);
|
||||
const empty = path.join(fx.root, "fresh-cache");
|
||||
const result = await acquireCorrespondingSources({
|
||||
cacheDirectory: empty,
|
||||
fetch: async (url) => {
|
||||
fetched.push(url);
|
||||
return Buffer.from(served.get(url) ?? "");
|
||||
},
|
||||
packageArchivePath: fx.packagePath,
|
||||
recipesDirectory: fx.recipesDirectory,
|
||||
supplementsDirectory: fx.supplementsDirectory,
|
||||
});
|
||||
expect(result).toMatchObject({ fetched: 5, files: 5 });
|
||||
// A second run finds everything verified and fetches nothing.
|
||||
const again = await acquireCorrespondingSources({
|
||||
cacheDirectory: empty,
|
||||
fetch: async () => {
|
||||
throw new Error("no network expected");
|
||||
},
|
||||
recipes: [{ name: "fake-native", version: "1.0.0" }],
|
||||
recipesDirectory: fx.recipesDirectory,
|
||||
});
|
||||
expect(again).toMatchObject({ fetched: 0, files: 5 });
|
||||
// The built artifact from the acquired cache is identical to one built from the original cache.
|
||||
const fromAcquired = await buildCorrespondingSource({
|
||||
cacheDirectory: empty,
|
||||
outDirectory: path.join(fx.root, "acquired-out"),
|
||||
packageArchivePath: fx.packagePath,
|
||||
recipesDirectory: fx.recipesDirectory,
|
||||
supplementsDirectory: fx.supplementsDirectory,
|
||||
});
|
||||
expect(fromAcquired.sourceSha256).toBe((await build(fx)).sourceSha256);
|
||||
|
||||
// A transient upstream failure is retried; only the recorded bytes are ever stored.
|
||||
const flaky = path.join(fx.root, "flaky-cache");
|
||||
const attempts = new Map<string, number>();
|
||||
await expect(
|
||||
acquireCorrespondingSources({
|
||||
cacheDirectory: flaky,
|
||||
fetch: async (url) => {
|
||||
const count = (attempts.get(url) ?? 0) + 1;
|
||||
attempts.set(url, count);
|
||||
if (count === 1) {
|
||||
throw new Error("connection reset");
|
||||
}
|
||||
return Buffer.from(served.get(url) ?? "");
|
||||
},
|
||||
recipes: [{ name: "fake-native", version: "1.0.0" }],
|
||||
recipesDirectory: fx.recipesDirectory,
|
||||
}),
|
||||
).resolves.toMatchObject({ fetched: 5 });
|
||||
|
||||
const tampered = path.join(fx.root, "tampered-cache");
|
||||
await rejects(
|
||||
acquireCorrespondingSources({
|
||||
cacheDirectory: tampered,
|
||||
fetch: async (url) => Buffer.from(`${served.get(url) ?? ""}upstream changed\n`),
|
||||
recipes: [{ name: "fake-native", version: "1.0.0" }],
|
||||
recipesDirectory: fx.recipesDirectory,
|
||||
}),
|
||||
"CORRESPONDING_SOURCE_ACQUISITION_FAILED",
|
||||
);
|
||||
const { readdir } = await import("node:fs/promises");
|
||||
expect(await readdir(path.join(tampered, "sha256"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds the repository's real sharp proof as a native Module Package for the hardened verifier
|
||||
// container test. Run inside deploy/Dockerfile's build stage, where the workspace is already built
|
||||
// and has exactly the locked dependencies the runtime image was built against.
|
||||
|
||||
import { cp, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { packModule } from "@ekklesia/module-pack";
|
||||
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, "..", "..", "..", "..");
|
||||
const outDirectory = process.argv[2];
|
||||
if (outDirectory === undefined || !path.isAbsolute(outDirectory)) {
|
||||
throw new Error("An absolute output directory is required.");
|
||||
}
|
||||
|
||||
const temporaryRoot = await mkdtemp(path.join(tmpdir(), "ekklesia-native-verifier-proof-"));
|
||||
try {
|
||||
const moduleDirectory = path.join(temporaryRoot, "native-proof");
|
||||
await cp(
|
||||
path.join(repositoryRoot, "packages", "module-pack", "tests", "fixtures", "native-module"),
|
||||
moduleDirectory,
|
||||
{ recursive: true },
|
||||
);
|
||||
const packageFile = path.join(moduleDirectory, "package.json");
|
||||
const packageJson = JSON.parse(await readFile(packageFile, "utf8"));
|
||||
packageJson.engines = { node: ">=24.11.0 <25" };
|
||||
await writeFile(packageFile, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
const modulesDirectory = path.join(moduleDirectory, "node_modules");
|
||||
await mkdir(path.join(modulesDirectory, "@ekklesia"), { recursive: true });
|
||||
await symlink(
|
||||
await realpath(path.join(repositoryRoot, "packages", "module-pack", "node_modules", "sharp")),
|
||||
path.join(modulesDirectory, "sharp"),
|
||||
);
|
||||
await symlink(
|
||||
await realpath(
|
||||
path.join(
|
||||
repositoryRoot,
|
||||
"packages",
|
||||
"module-pack",
|
||||
"node_modules",
|
||||
"@ekklesia",
|
||||
"module-sdk",
|
||||
),
|
||||
),
|
||||
path.join(modulesDirectory, "@ekklesia", "module-sdk"),
|
||||
);
|
||||
|
||||
const result = await packModule({
|
||||
licenseFile: path.join(repositoryRoot, "LICENSE"),
|
||||
moduleDirectory,
|
||||
outDirectory,
|
||||
sourceRevision: "0".repeat(40),
|
||||
target: "linux-x64-glibc",
|
||||
});
|
||||
process.stdout.write(`${path.basename(result.archivePath)}\n`);
|
||||
} finally {
|
||||
await rm(temporaryRoot, { force: true, recursive: true });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-sdk",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -37,6 +37,7 @@ export {
|
||||
isDevicePrincipal,
|
||||
isModuleHttpMethod,
|
||||
isModuleRouteBodyMode,
|
||||
isModuleStorageError,
|
||||
isValidModuleStorageKey,
|
||||
isValidModuleStorageReadUrlFilename,
|
||||
MODULE_HTTP_BODY_MODES,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-ui-build",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-ui-host",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-ui-sdk",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/typescript-config",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/ui",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
type FormEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "./controls.js";
|
||||
import { Dialog } from "./surfaces.js";
|
||||
|
||||
export type ConfirmationTone = "default" | "destructive";
|
||||
|
||||
export interface ConfirmationDialogProps {
|
||||
readonly open: boolean;
|
||||
/** The question, as the dialog's accessible name: "Delete Foyer-1?" */
|
||||
readonly title: ReactNode;
|
||||
/** What will happen, announced with the name: name the resource and the consequence. */
|
||||
readonly description?: ReactNode;
|
||||
/** Anything more the person should weigh — whether it can be undone, what else it affects. */
|
||||
readonly children?: ReactNode;
|
||||
/** The confirming action, named for what it does: "Delete display", never "OK". */
|
||||
readonly confirmLabel: string;
|
||||
readonly cancelLabel?: string;
|
||||
/**
|
||||
* `destructive` for an action whose effect matters. It is marked by a symbol and wording as well
|
||||
* as by colour, and focus starts on Cancel rather than on the action. The consuming feature
|
||||
* decides the severity; this element never upgrades or downgrades it.
|
||||
*/
|
||||
readonly tone?: ConfirmationTone;
|
||||
/**
|
||||
* Typed confirmation, for the most serious actions (DESIGN.md §8.2): the exact phrase the person
|
||||
* must type before Confirm is enabled — `DELETE`, or the resource's own name. Leave it unset for
|
||||
* every action that does not need it.
|
||||
*/
|
||||
readonly requiredPhrase?: string;
|
||||
/** The label of the typed-confirmation field. Default: "Type <phrase> to confirm". */
|
||||
readonly requiredPhraseLabel?: ReactNode;
|
||||
/**
|
||||
* The confirmed action is in progress: both actions are disabled, Escape does nothing, and the
|
||||
* dialog is marked busy, so the person cannot leave it half-done. The caller closes it when done.
|
||||
*/
|
||||
readonly busy?: boolean;
|
||||
readonly onConfirm: () => void;
|
||||
readonly onCancel: () => void;
|
||||
readonly "data-testid"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a typed value matches a required phrase.
|
||||
*
|
||||
* Exact and case-sensitive, because typing it is meant to be deliberate — `delete` is not `DELETE`.
|
||||
* Only what a phone keyboard adds without being asked is forgiven: whitespace around the value, and
|
||||
* a different Unicode composition of the same characters (NFC).
|
||||
*/
|
||||
export function matchesRequiredPhrase(value: string, phrase: string): boolean {
|
||||
return value.trim().normalize("NFC") === phrase.trim().normalize("NFC");
|
||||
}
|
||||
|
||||
/**
|
||||
* The product-owned confirmation every Ekklesia surface uses instead of `window.confirm` (DESIGN.md
|
||||
* §8, ADR 0059).
|
||||
*
|
||||
* Three severities from one element, chosen by the caller: a simple confirmation; a destructive one;
|
||||
* and a destructive one that also requires a typed phrase. It is a {@link Dialog}: modal, named by
|
||||
* its title and described by its description, with focus trapped inside and returned to the control
|
||||
* that opened it. Cancel is always present and always safe. Enter confirms only once a required
|
||||
* phrase matches.
|
||||
*/
|
||||
export function ConfirmationDialog(props: ConfirmationDialogProps): ReactElement | null {
|
||||
const tone = props.tone ?? "default";
|
||||
const busy = props.busy ?? false;
|
||||
const phraseId = useId();
|
||||
const phraseHintId = useId();
|
||||
const [typed, setTyped] = useState("");
|
||||
const phraseRef = useRef<HTMLInputElement | null>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement | null>(null);
|
||||
const confirmRef = useRef<HTMLButtonElement | null>(null);
|
||||
const requiresPhrase = props.requiredPhrase !== undefined;
|
||||
const phraseMatches =
|
||||
props.requiredPhrase === undefined || matchesRequiredPhrase(typed, props.requiredPhrase);
|
||||
const canConfirm = phraseMatches && !busy;
|
||||
|
||||
// A phrase typed for one resource must never carry over to the next time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!props.open) {
|
||||
setTyped("");
|
||||
}
|
||||
}, [props.open]);
|
||||
|
||||
// A required phrase starts at its field; a destructive action starts on the safe choice; a simple
|
||||
// confirmation starts on the action itself.
|
||||
const initialFocusRef = requiresPhrase
|
||||
? phraseRef
|
||||
: tone === "destructive"
|
||||
? cancelRef
|
||||
: confirmRef;
|
||||
|
||||
const cancel = (): void => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
setTyped("");
|
||||
props.onCancel();
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
if (canConfirm) {
|
||||
props.onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...(tone === "destructive" ? { className: "ek-confirmation--destructive" } : {})}
|
||||
{...(props["data-testid"] === undefined ? {} : { "data-testid": props["data-testid"] })}
|
||||
{...(props.description === undefined ? {} : { description: props.description })}
|
||||
dismissible={!busy}
|
||||
initialFocusRef={initialFocusRef}
|
||||
onDismiss={cancel}
|
||||
open={props.open}
|
||||
title={
|
||||
tone === "destructive" ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="ek-confirmation__symbol" />
|
||||
{props.title}
|
||||
</>
|
||||
) : (
|
||||
props.title
|
||||
)
|
||||
}
|
||||
>
|
||||
<form aria-busy={busy || undefined} className="ek-confirmation" onSubmit={submit}>
|
||||
{props.children}
|
||||
{props.requiredPhrase === undefined ? undefined : (
|
||||
<div className="ek-field">
|
||||
<label className="ek-field__label" htmlFor={phraseId}>
|
||||
{props.requiredPhraseLabel ?? (
|
||||
<>
|
||||
Type <strong className="ek-confirmation__phrase">{props.requiredPhrase}</strong>{" "}
|
||||
to confirm
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
aria-describedby={phraseHintId}
|
||||
aria-invalid={typed.length > 0 && !phraseMatches ? true : undefined}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="ek-input ek-focusable"
|
||||
disabled={busy}
|
||||
id={phraseId}
|
||||
onChange={(event) => setTyped(event.currentTarget.value)}
|
||||
ref={phraseRef}
|
||||
spellCheck={false}
|
||||
type="text"
|
||||
value={typed}
|
||||
/>
|
||||
<span aria-live="polite" className="ek-field__hint" id={phraseHintId}>
|
||||
{phraseMatches
|
||||
? "Matches. You can confirm."
|
||||
: typed.length === 0
|
||||
? "Confirm stays unavailable until this matches exactly."
|
||||
: "Does not match yet. It must match exactly, including capitals."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ek-dialog__actions">
|
||||
<Button disabled={busy} onClick={cancel} ref={cancelRef}>
|
||||
{props.cancelLabel ?? "Cancel"}
|
||||
</Button>
|
||||
<Button
|
||||
aria-describedby={requiresPhrase && !phraseMatches ? phraseHintId : undefined}
|
||||
disabled={!canConfirm}
|
||||
ref={confirmRef}
|
||||
type="submit"
|
||||
variant={tone === "destructive" ? "danger" : "primary"}
|
||||
>
|
||||
{tone === "destructive" ? (
|
||||
<span aria-hidden="true" className="ek-confirmation__symbol" />
|
||||
) : undefined}
|
||||
{props.confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+173
-20
@@ -1,4 +1,4 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { type CSSProperties, type ReactElement, type ReactNode, useId } from "react";
|
||||
import { classNames } from "./class-names.js";
|
||||
|
||||
/**
|
||||
@@ -48,6 +48,17 @@ export interface ToastProps {
|
||||
readonly title: ReactNode;
|
||||
readonly className?: string;
|
||||
readonly children?: ReactNode;
|
||||
/**
|
||||
* Whether this toast is its own live region. Default `true`, for a toast a caller renders on its
|
||||
* own. A {@link ToastProvider} renders toasts inside live regions it already owns and turns this
|
||||
* off, so nothing is announced twice.
|
||||
*/
|
||||
readonly announce?: boolean;
|
||||
/** Renders a dismiss control. */
|
||||
readonly onDismiss?: () => void;
|
||||
/** The dismiss control's accessible name. Default "Dismiss notification". */
|
||||
readonly dismissLabel?: string;
|
||||
readonly "data-toast-id"?: string;
|
||||
}
|
||||
|
||||
export interface InlineNoticeProps {
|
||||
@@ -79,27 +90,40 @@ export function InlineNotice(props: InlineNoticeProps): ReactElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* One notification's presentation — and only its presentation.
|
||||
* One notification's presentation.
|
||||
*
|
||||
* There is deliberately no toast *manager*: no queue, no timeout, no global store, no imperative
|
||||
* `toast()` function. Queuing policy belongs to whichever application is rendering, and a
|
||||
* package-level singleton would be shared mutable state reaching across every isolated artifact on
|
||||
* a page. A caller renders these where it wants them, for as long as it wants them.
|
||||
* The queue, timing, and placement belong to {@link ToastProvider} (ADR 0059), which is per React
|
||||
* tree rather than a package singleton: a module-level store would be shared mutable state reaching
|
||||
* across every isolated artifact on a page. A caller may still render one of these on its own.
|
||||
*/
|
||||
export function Toast(props: ToastProps): ReactElement {
|
||||
const tone = props.tone ?? "info";
|
||||
const announce = props.announce ?? true;
|
||||
return (
|
||||
<div
|
||||
aria-live={tone === "danger" ? "assertive" : "polite"}
|
||||
aria-live={announce ? (tone === "danger" ? "assertive" : "polite") : undefined}
|
||||
className={classNames("ek-toast", `ek-toast--${tone}`, props.className)}
|
||||
role="status"
|
||||
data-toast-id={props["data-toast-id"]}
|
||||
role={announce ? "status" : undefined}
|
||||
>
|
||||
<div className="ek-toast__title">
|
||||
<span aria-hidden="true">{statusSymbols[tone]} </span>
|
||||
{props.title}
|
||||
<div className="ek-toast__content">
|
||||
<div className="ek-toast__title">
|
||||
<span aria-hidden="true">{statusSymbols[tone]} </span>
|
||||
{props.title}
|
||||
</div>
|
||||
{props.children === undefined ? undefined : (
|
||||
<div className="ek-toast__body">{props.children}</div>
|
||||
)}
|
||||
</div>
|
||||
{props.children === undefined ? undefined : (
|
||||
<div className="ek-toast__body">{props.children}</div>
|
||||
{props.onDismiss === undefined ? undefined : (
|
||||
<button
|
||||
aria-label={props.dismissLabel ?? "Dismiss notification"}
|
||||
className="ek-button ek-button--quiet ek-button--sm ek-focusable ek-toast__dismiss"
|
||||
onClick={props.onDismiss}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -143,28 +167,157 @@ export function EmptyState(props: EmptyStateProps): ReactElement {
|
||||
}
|
||||
|
||||
export interface ErrorStateProps {
|
||||
/** What failed, in plain words: "The displays could not be loaded." */
|
||||
readonly title: ReactNode;
|
||||
/** What the person can do about it, and whether trying again makes sense. */
|
||||
readonly description?: ReactNode;
|
||||
/** A retry control, or whatever else the caller can honestly offer. */
|
||||
/** One recovery control. Kept for existing callers; prefer `actions`. */
|
||||
readonly action?: ReactNode;
|
||||
/**
|
||||
* Recovery and remedy controls — Retry, a link to the setting that fixes it. A caller that knows
|
||||
* the viewer can administer the problem passes the administrator's remedies here as well; the
|
||||
* element has no notion of roles.
|
||||
*/
|
||||
readonly actions?: ReactNode;
|
||||
/**
|
||||
* Technical detail for whoever needs it: an error code, the failing step, a likely cause. Plain
|
||||
* text, collapsed behind a disclosure, so it never dominates the message. Rendered as text — markup
|
||||
* in it is shown, never interpreted — with control characters removed and its length bounded.
|
||||
*/
|
||||
readonly details?: string;
|
||||
/** The disclosure's label. Default "Technical details". */
|
||||
readonly detailsLabel?: string;
|
||||
/** A diagnostic id that can be quoted to an administrator or found in a log, when it is safe to
|
||||
* show. Shown inside the details. */
|
||||
readonly reference?: string;
|
||||
/**
|
||||
* Whether the error is announced when it appears (`role="alert"`). Default `true`. Turn it off for
|
||||
* an error that is part of a page's initial content rather than a change the person caused.
|
||||
*/
|
||||
readonly announce?: boolean;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
/** Technical detail is bounded so an enormous server message cannot bury the page. */
|
||||
export const ERROR_DETAILS_MAX_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* The failure a person sees.
|
||||
* Makes an arbitrary string safe to show as technical detail: strips control characters (keeping
|
||||
* line breaks and tabs), trims, and bounds its length. React already renders it as text, so markup
|
||||
* cannot execute; this keeps it legible.
|
||||
*/
|
||||
export function errorDetailsText(value: string): string {
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: removing control characters is the point.
|
||||
const cleaned = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "").trim();
|
||||
return cleaned.length > ERROR_DETAILS_MAX_LENGTH
|
||||
? `${cleaned.slice(0, ERROR_DETAILS_MAX_LENGTH)}…`
|
||||
: cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure a person sees (DESIGN.md §7.3): what failed, what they can do, and — only if they want
|
||||
* it — the technical detail.
|
||||
*
|
||||
* Callers pass a fixed, human message. **Never** pass a caught exception, a stack, a URL, a
|
||||
* filesystem path, or a raw server response: every Ekklesia error boundary is sanitized, and this
|
||||
* component is where that discipline becomes visible to a user.
|
||||
* The message is the caller's fixed, human wording. **Never** pass a caught exception or its stack:
|
||||
* `details` takes a string, chosen deliberately — a sanitized error code, the step that failed —
|
||||
* and stays collapsed until opened.
|
||||
*/
|
||||
export function ErrorState(props: ErrorStateProps): ReactElement {
|
||||
const details = props.details === undefined ? "" : errorDetailsText(props.details);
|
||||
const reference = props.reference === undefined ? "" : errorDetailsText(props.reference);
|
||||
const announce = props.announce ?? true;
|
||||
return (
|
||||
<div className={classNames("ek-message", "ek-message--error", props.className)} role="alert">
|
||||
<div
|
||||
className={classNames("ek-message", "ek-message--error", props.className)}
|
||||
role={announce ? "alert" : undefined}
|
||||
>
|
||||
<p className="ek-message__title">{props.title}</p>
|
||||
{props.description === undefined ? undefined : (
|
||||
<p className="ek-message__body">{props.description}</p>
|
||||
)}
|
||||
{props.action}
|
||||
{props.action === undefined && props.actions === undefined ? undefined : (
|
||||
<div className="ek-message__actions">
|
||||
{props.action}
|
||||
{props.actions}
|
||||
</div>
|
||||
)}
|
||||
{details === "" && reference === "" ? undefined : (
|
||||
<details className="ek-message__details">
|
||||
<summary className="ek-focusable">{props.detailsLabel ?? "Technical details"}</summary>
|
||||
{details === "" ? undefined : <pre className="ek-message__details-text">{details}</pre>}
|
||||
{reference === "" ? undefined : (
|
||||
<p className="ek-message__reference">
|
||||
Reference: <code>{reference}</code>
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SkeletonProps {
|
||||
/** `text` is one line of text; `block` an image, chart, or card; `circle` an avatar. */
|
||||
readonly shape?: "text" | "block" | "circle";
|
||||
/** Any CSS length. Text defaults to the full width; a circle is as wide as it is tall. */
|
||||
readonly width?: string;
|
||||
readonly height?: string;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A placeholder in the shape of content that is on its way (DESIGN.md §7.1), for content whose
|
||||
* shape is known. Decorative: hidden from assistive technology, which hears its
|
||||
* {@link SkeletonGroup}'s label instead. It pulses gently, and not at all for a person who asked
|
||||
* their system for reduced motion. There is no progress in it, because there is none to report.
|
||||
*/
|
||||
export function Skeleton(props: SkeletonProps): ReactElement {
|
||||
const shape = props.shape ?? "text";
|
||||
const style: CSSProperties = {};
|
||||
if (props.width !== undefined) {
|
||||
style.width = props.width;
|
||||
}
|
||||
if (props.height !== undefined) {
|
||||
style.height = props.height;
|
||||
}
|
||||
if (shape === "circle" && props.width !== undefined && props.height === undefined) {
|
||||
style.height = props.width;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames("ek-skeleton", `ek-skeleton--${shape}`, props.className)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SkeletonGroupProps {
|
||||
/** What is loading, announced once: "Loading displays". */
|
||||
readonly label: string;
|
||||
readonly children?: ReactNode;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The loading region a set of {@link Skeleton}s stands in for. It is `aria-busy` and announces its
|
||||
* label once, politely, rather than every placeholder; when the real content replaces the group,
|
||||
* the busy region is gone with it.
|
||||
*/
|
||||
export function SkeletonGroup(props: SkeletonGroupProps): ReactElement {
|
||||
const labelId = useId();
|
||||
return (
|
||||
<div
|
||||
aria-busy="true"
|
||||
aria-labelledby={labelId}
|
||||
aria-live="polite"
|
||||
className={classNames("ek-skeleton-group", props.className)}
|
||||
role="status"
|
||||
>
|
||||
<span className="ek-visually-hidden" id={labelId}>
|
||||
{props.label}
|
||||
</span>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { type KeyboardEvent, type RefObject, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
/** Everything a keyboard can land on. Disabled controls and `tabindex="-1"` are skipped. */
|
||||
const FOCUSABLE = [
|
||||
"a[href]",
|
||||
"area[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled]):not([type='hidden'])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"summary",
|
||||
"[contenteditable='true']",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(", ");
|
||||
|
||||
/** The focused element as seen from the container's own root: its shadow root inside an isolated
|
||||
* module artifact (ADR 0040), where the document would only report the shadow host, or the document. */
|
||||
function activeWithin(container: HTMLElement): Element | null {
|
||||
const root = container.getRootNode() as Document | ShadowRoot;
|
||||
return root.activeElement ?? null;
|
||||
}
|
||||
|
||||
function focusableWithin(container: HTMLElement): HTMLElement[] {
|
||||
return [...container.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
|
||||
(element) => !element.closest("[inert]"),
|
||||
);
|
||||
}
|
||||
|
||||
export interface FocusTrapOptions {
|
||||
/** Where focus starts when the trap activates. Without one, the container itself. */
|
||||
readonly initialFocus?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps keyboard focus inside a modal surface while it is open, and puts it back afterwards.
|
||||
*
|
||||
* - On activation, remembers what had focus and moves focus to `initialFocus` (or the container).
|
||||
* - While active, Tab from the last focusable element wraps to the first and Shift+Tab from the
|
||||
* first wraps to the last, so focus cannot walk out into the page behind.
|
||||
* - On deactivation or unmount, returns focus to whatever had it before — the control that opened
|
||||
* the surface — if that control is still in the document.
|
||||
*
|
||||
* Returns the `onKeyDown` handler the container must carry. It works inside an isolated module root
|
||||
* as well as in a document: it only ever looks inside its own container and at the container's own
|
||||
* root node, never at a global. This is the one focus trap ADR 0039 deferred until the platform needed
|
||||
* it (ADR 0059); every modal surface in `@ekklesia/ui` uses it.
|
||||
*/
|
||||
export function useFocusTrap(
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
active: boolean,
|
||||
options: FocusTrapOptions = {},
|
||||
): (event: KeyboardEvent<HTMLElement>) => void {
|
||||
const { initialFocus } = options;
|
||||
const restoreTo = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!active || !container) {
|
||||
return;
|
||||
}
|
||||
const previous = activeWithin(container);
|
||||
restoreTo.current =
|
||||
previous && previous !== container.ownerDocument.body && "focus" in previous
|
||||
? (previous as HTMLElement)
|
||||
: null;
|
||||
(initialFocus?.current ?? container).focus();
|
||||
return () => {
|
||||
const target = restoreTo.current;
|
||||
restoreTo.current = null;
|
||||
if (target?.isConnected) {
|
||||
target.focus();
|
||||
}
|
||||
};
|
||||
}, [active, containerRef, initialFocus]);
|
||||
|
||||
return useCallback(
|
||||
(event: KeyboardEvent<HTMLElement>) => {
|
||||
const container = containerRef.current;
|
||||
if (!active || event.key !== "Tab" || !container) {
|
||||
return;
|
||||
}
|
||||
const focusable = focusableWithin(container);
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (!first || !last) {
|
||||
event.preventDefault();
|
||||
container.focus();
|
||||
return;
|
||||
}
|
||||
const current = activeWithin(container);
|
||||
const outside = !(current instanceof Node) || !container.contains(current);
|
||||
if (event.shiftKey && (current === first || current === container || outside)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (current === last || outside)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
},
|
||||
[active, containerRef],
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,11 @@ export {
|
||||
type TableRowProps,
|
||||
type TableSectionProps,
|
||||
} from "./data.js";
|
||||
export {
|
||||
ConfirmationDialog,
|
||||
type ConfirmationDialogProps,
|
||||
type ConfirmationTone,
|
||||
} from "./confirmation.js";
|
||||
export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js";
|
||||
export {
|
||||
Badge,
|
||||
@@ -56,6 +61,10 @@ export {
|
||||
type InlineNoticeProps,
|
||||
LoadingState,
|
||||
type LoadingStateProps,
|
||||
Skeleton,
|
||||
SkeletonGroup,
|
||||
type SkeletonGroupProps,
|
||||
type SkeletonProps,
|
||||
Toast,
|
||||
type ToastProps,
|
||||
ToastStack,
|
||||
@@ -75,8 +84,17 @@ export {
|
||||
Panel,
|
||||
type PanelProps,
|
||||
PageHeader,
|
||||
type PageHeaderBack,
|
||||
type PageHeaderProps,
|
||||
type TabDefinition,
|
||||
Tabs,
|
||||
type TabsProps,
|
||||
} from "./surfaces.js";
|
||||
export {
|
||||
type ToastApi,
|
||||
type ToastOptions,
|
||||
ToastProvider,
|
||||
type ToastProviderProps,
|
||||
type ToastTone,
|
||||
useToasts,
|
||||
} from "./toasts.js";
|
||||
|
||||
+172
-1
@@ -595,14 +595,185 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
width: ${designToken("spacing.xl")};
|
||||
}
|
||||
|
||||
.ek-visually-hidden {
|
||||
border: 0;
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.ek-dialog__description {
|
||||
color: ${designToken("text.secondary")};
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ek-confirmation {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.lg")};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* A destructive confirmation says so with a symbol and a heavier frame, never by colour alone. */
|
||||
.ek-confirmation--destructive {
|
||||
border-color: ${designToken("status.danger-border")};
|
||||
border-width: calc(${designToken("border.width")} * 2);
|
||||
}
|
||||
|
||||
.ek-confirmation__symbol::before {
|
||||
content: ${designToken("status.warning-symbol")};
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
margin-inline-end: ${designToken("spacing.xs")};
|
||||
}
|
||||
|
||||
.ek-confirmation__phrase {
|
||||
font-family: ${designToken("typography.family-mono")};
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ek-toast-region {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.sm")};
|
||||
left: 50%;
|
||||
max-width: calc(100vw - (${designToken("spacing.md")} * 2));
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
top: ${designToken("spacing.lg")};
|
||||
transform: translateX(-50%);
|
||||
width: 28rem;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.ek-toast-region__list {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.sm")};
|
||||
}
|
||||
|
||||
.ek-toast-region .ek-toast {
|
||||
animation: ek-toast-enter 160ms ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ek-toast {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
gap: ${designToken("spacing.sm")};
|
||||
}
|
||||
|
||||
.ek-toast__content {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
gap: ${designToken("spacing.2xs")};
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ek-toast__dismiss {
|
||||
flex: 0 0 auto;
|
||||
min-width: ${designToken("control.height-sm")};
|
||||
}
|
||||
|
||||
.ek-message__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${designToken("spacing.sm")};
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ek-message__details {
|
||||
color: ${designToken("text.secondary")};
|
||||
font-size: ${designToken("typography.size-xs")};
|
||||
max-width: 100%;
|
||||
text-align: start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ek-message__details summary {
|
||||
cursor: pointer;
|
||||
min-height: ${designToken("control.height-sm")};
|
||||
padding-block: ${designToken("spacing.2xs")};
|
||||
}
|
||||
|
||||
.ek-message__details-text {
|
||||
background: ${designToken("background.inset")};
|
||||
border-radius: ${designToken("radius.sm")};
|
||||
font-family: ${designToken("typography.family-mono")};
|
||||
margin: ${designToken("spacing.xs")} 0 0;
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
padding: ${designToken("spacing.sm")};
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ek-message__reference {
|
||||
margin: ${designToken("spacing.xs")} 0 0;
|
||||
}
|
||||
|
||||
.ek-page-header__back {
|
||||
justify-self: start;
|
||||
margin-inline-start: calc(-1 * ${designToken("control.padding-inline")});
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ek-skeleton-group {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.sm")};
|
||||
}
|
||||
|
||||
.ek-skeleton {
|
||||
animation: ek-skeleton-pulse 1.6s ease-in-out infinite;
|
||||
background: ${designToken("background.inset")};
|
||||
border-radius: ${designToken("radius.sm")};
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ek-skeleton--text {
|
||||
height: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ek-skeleton--block {
|
||||
height: 6rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ek-skeleton--circle {
|
||||
border-radius: ${designToken("radius.pill")};
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
@keyframes ek-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ek-skeleton-pulse {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ek-toast-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Every animation in these elements stops for a person who asked for reduced motion; nothing in
|
||||
them depends on movement to be understood. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ek-spinner {
|
||||
.ek-spinner,
|
||||
.ek-skeleton,
|
||||
.ek-toast-region .ek-toast {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
+126
-23
@@ -1,29 +1,101 @@
|
||||
import {
|
||||
type HTMLAttributes,
|
||||
type MouseEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
type RefObject,
|
||||
useId,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { classNames } from "./class-names.js";
|
||||
import { useFocusTrap } from "./focus-trap.js";
|
||||
|
||||
/** Containers: the panel a screen is built from, the dialog that interrupts it, and the tab strip
|
||||
* that divides it. */
|
||||
|
||||
/**
|
||||
* A page's Back action. The caller decides where Back goes: a host that knows its own routes
|
||||
* validates any return target before handing it over (ADR 0059); this element only renders it.
|
||||
*/
|
||||
export interface PageHeaderBack {
|
||||
/** Visible and accessible. Name the destination where it helps: "Back to Signage". */
|
||||
readonly label: string;
|
||||
/** When Back is a real place, its address: rendered as a link, so it can be opened in a new tab
|
||||
* and is announced as a link. Without one, Back is a button. */
|
||||
readonly href?: string;
|
||||
/** Called on an ordinary activation. A click with a modifier key or the middle button on a link
|
||||
* is left to the browser. */
|
||||
readonly onBack: () => void;
|
||||
}
|
||||
|
||||
export interface PageHeaderProps {
|
||||
readonly title: ReactNode;
|
||||
readonly description?: ReactNode;
|
||||
readonly actions?: ReactNode;
|
||||
readonly eyebrow?: ReactNode;
|
||||
/** The Back action of a nested page (DESIGN.md §4.8): Back plus the page title is the baseline;
|
||||
* breadcrumbs are not part of it. */
|
||||
readonly back?: PageHeaderBack;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
/** The consistent top-level identity and primary-action region for an Ekklesia screen. */
|
||||
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
|
||||
return (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
);
|
||||
}
|
||||
|
||||
function BackAction(props: { readonly back: PageHeaderBack }): ReactElement {
|
||||
const { back } = props;
|
||||
const content = (
|
||||
<>
|
||||
<span aria-hidden="true" className="ek-page-header__back-symbol">
|
||||
←
|
||||
</span>
|
||||
{back.label}
|
||||
</>
|
||||
);
|
||||
const className = "ek-button ek-button--quiet ek-button--sm ek-focusable ek-page-header__back";
|
||||
if (back.href !== undefined) {
|
||||
return (
|
||||
<a
|
||||
className={className}
|
||||
href={back.href}
|
||||
onClick={(event) => {
|
||||
if (isBrowserHandledClick(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
back.onBack();
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button className={className} onClick={back.onBack} type="button">
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The consistent top-level identity and primary-action region for an Ekklesia screen.
|
||||
*
|
||||
* The title is the page's one `h1`; a section within the page is a `Panel` with its own `h2`. A
|
||||
* nested page puts its Back action above the title, and page-level actions sit opposite it.
|
||||
*/
|
||||
export function PageHeader(props: PageHeaderProps): ReactElement {
|
||||
return (
|
||||
<header className={classNames("ek-page-header", props.className)}>
|
||||
<div className="ek-page-header__copy">
|
||||
{props.back === undefined ? undefined : <BackAction back={props.back} />}
|
||||
{props.eyebrow === undefined ? undefined : (
|
||||
<span className="ek-page-header__eyebrow">{props.eyebrow}</span>
|
||||
)}
|
||||
@@ -98,12 +170,23 @@ export function Panel(props: PanelProps): ReactElement {
|
||||
|
||||
export interface DialogProps {
|
||||
readonly open: boolean;
|
||||
/** The dialog's accessible name, rendered as its heading. */
|
||||
readonly title: ReactNode;
|
||||
/** A short statement of what the dialog is about, announced with its name (`aria-describedby`). */
|
||||
readonly description?: ReactNode;
|
||||
readonly onDismiss: () => void;
|
||||
/**
|
||||
* Whether Escape dismisses the dialog. Default `true`. A caller turns it off while something is
|
||||
* in progress that dismissing would leave half-done.
|
||||
*/
|
||||
readonly dismissible?: boolean;
|
||||
/** Where focus starts. Without one, the dialog itself, so its name and description are read. */
|
||||
readonly initialFocusRef?: RefObject<HTMLElement | null>;
|
||||
/** Buttons for the dialog's decisions, rendered in a trailing-aligned row. */
|
||||
readonly actions?: ReactNode;
|
||||
readonly className?: string;
|
||||
readonly children?: ReactNode;
|
||||
readonly "data-testid"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,43 +195,58 @@ export interface DialogProps {
|
||||
* Portalling is what a document-scoped component library does. It is exactly wrong here: a module
|
||||
* artifact's dialog portalled to the host document would escape the artifact's isolated root, lose
|
||||
* its styles, and render over the Dashboard — which is the leak this package exists to prevent. The
|
||||
* scrim is `position: fixed`, so it still covers the viewport from wherever it is rendered.
|
||||
* scrim is `position: fixed`, so it still covers the viewport from wherever it is rendered, and it
|
||||
* takes every pointer event, so nothing behind the dialog can be clicked.
|
||||
*
|
||||
* Focus moves to the dialog when it opens and `Escape` dismisses it. A full focus trap is
|
||||
* deliberately not implemented here rather than implemented badly; when the platform needs one it
|
||||
* belongs in one place, tested, not sprinkled through call sites.
|
||||
* While open, keyboard focus is trapped inside it ({@link useFocusTrap}): Tab and Shift+Tab wrap,
|
||||
* and focus returns to the control that opened it when it closes. Escape dismisses it unless the
|
||||
* caller says that is not safe right now. Escape is handled on the dialog itself and goes no
|
||||
* further, so a drawer or menu the dialog was opened from does not close with it. Assistive
|
||||
* technology is told it is modal (`aria-modal`), named by its title, and described by its
|
||||
* description.
|
||||
*/
|
||||
export function Dialog(props: DialogProps): ReactElement | null {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const { onDismiss, open } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
dialogRef.current?.focus();
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
const owner = dialogRef.current?.ownerDocument;
|
||||
owner?.addEventListener("keydown", onKeyDown);
|
||||
return () => owner?.removeEventListener("keydown", onKeyDown);
|
||||
}, [onDismiss, open]);
|
||||
const dismissible = props.dismissible ?? true;
|
||||
const trapKeyDown = useFocusTrap(dialogRef, open, {
|
||||
...(props.initialFocusRef ? { initialFocus: props.initialFocusRef } : {}),
|
||||
});
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ek-dialog__scrim">
|
||||
<div
|
||||
className="ek-dialog__scrim"
|
||||
// The scrim is the barrier between the dialog and the page: a press on it reaches nothing
|
||||
// behind it and does not dismiss the dialog, which only ever closes deliberately.
|
||||
onPointerDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-describedby={props.description === undefined ? undefined : descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className={classNames("ek-dialog", "ek-focusable", props.className)}
|
||||
data-testid={props["data-testid"]}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (dismissible) {
|
||||
onDismiss();
|
||||
}
|
||||
return;
|
||||
}
|
||||
trapKeyDown(event);
|
||||
}}
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
@@ -156,6 +254,11 @@ export function Dialog(props: DialogProps): ReactElement | null {
|
||||
<h2 className="ek-dialog__title" id={titleId}>
|
||||
{props.title}
|
||||
</h2>
|
||||
{props.description === undefined ? undefined : (
|
||||
<div className="ek-dialog__description" id={descriptionId}>
|
||||
{props.description}
|
||||
</div>
|
||||
)}
|
||||
{props.children}
|
||||
{props.actions === undefined ? undefined : (
|
||||
<div className="ek-dialog__actions">{props.actions}</div>
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import {
|
||||
createContext,
|
||||
type FocusEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Toast } from "./feedback.js";
|
||||
|
||||
/** The four severities a toast can carry. `danger` is an error. */
|
||||
export type ToastTone = "success" | "info" | "warning" | "danger";
|
||||
|
||||
export interface ToastOptions {
|
||||
readonly tone?: ToastTone;
|
||||
/** What happened, in a few words. Also names the toast's dismiss control. */
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
/**
|
||||
* How long it stays, in milliseconds, or `"persistent"` to stay until dismissed. Defaults by
|
||||
* severity: an error stays until dismissed; anything else lasts long enough to read, and never
|
||||
* less than three seconds (DESIGN.md §7.4).
|
||||
*/
|
||||
readonly duration?: number | "persistent";
|
||||
}
|
||||
|
||||
export interface ToastApi {
|
||||
/** Shows a toast and returns its id. An identical toast already showing is refreshed instead. */
|
||||
show(options: ToastOptions): string;
|
||||
dismiss(id: string): void;
|
||||
}
|
||||
|
||||
/** DESIGN.md §7.4's floor: no toast disappears in under about three seconds. */
|
||||
export const TOAST_MINIMUM_DURATION_MS = 3000;
|
||||
const DEFAULT_DURATION_MS: Readonly<Record<Exclude<ToastTone, "danger">, number>> = Object.freeze({
|
||||
info: 4000,
|
||||
success: 4000,
|
||||
warning: 6000,
|
||||
});
|
||||
const READING_MS_PER_CHARACTER = 50;
|
||||
const LONGEST_TIMED_DURATION_MS = 12_000;
|
||||
const DEFAULT_MAX_VISIBLE = 3;
|
||||
|
||||
/**
|
||||
* How long a toast stays: its requested or default duration, but never less than it takes to read.
|
||||
* Longer messages stay longer, up to a ceiling; an error stays until dismissed unless its caller
|
||||
* deliberately gives it a duration (a routine validation error, say).
|
||||
*/
|
||||
export function toastDuration(options: ToastOptions): number | "persistent" {
|
||||
const tone = options.tone ?? "info";
|
||||
const requested =
|
||||
options.duration ?? (tone === "danger" ? "persistent" : DEFAULT_DURATION_MS[tone]);
|
||||
if (requested === "persistent") {
|
||||
return "persistent";
|
||||
}
|
||||
const characters = options.title.length + (options.description?.length ?? 0);
|
||||
const readingTime = Math.min(
|
||||
LONGEST_TIMED_DURATION_MS,
|
||||
TOAST_MINIMUM_DURATION_MS + characters * READING_MS_PER_CHARACTER,
|
||||
);
|
||||
return Math.max(TOAST_MINIMUM_DURATION_MS, requested, readingTime);
|
||||
}
|
||||
|
||||
interface ToastRecord {
|
||||
readonly id: string;
|
||||
readonly tone: ToastTone;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly duration: number | "persistent";
|
||||
readonly key: string;
|
||||
}
|
||||
|
||||
/** A running or paused countdown. `remaining` is what is left when it was last started. */
|
||||
interface ToastTimer {
|
||||
handle: ReturnType<typeof setTimeout> | undefined;
|
||||
remaining: number;
|
||||
started: number;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
/**
|
||||
* The toast API of the nearest {@link ToastProvider}. Throws outside one, because a toast shown
|
||||
* nowhere is a silent failure.
|
||||
*/
|
||||
export function useToasts(): ToastApi {
|
||||
const api = useContext(ToastContext);
|
||||
if (!api) {
|
||||
throw new Error("useToasts must be used inside a ToastProvider.");
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
export interface ToastProviderProps {
|
||||
readonly children?: ReactNode;
|
||||
/** Names the notification region for assistive technology. */
|
||||
readonly label?: string;
|
||||
/** How many non-error toasts show at once. Errors are never displaced to make room. */
|
||||
readonly maxVisible?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The toast system of one React tree (ADR 0059): a top-center region and the queue behind it.
|
||||
*
|
||||
* **Per tree, never a package singleton.** A module mounts in its own isolated root (ADR 0040) with
|
||||
* its own React tree; a module-level store shared through this package would be mutable state
|
||||
* reaching across every artifact on the page. Each provider owns its toasts, its timers, and its
|
||||
* region, and is torn down with its tree — every timer with it.
|
||||
*
|
||||
* **Calm under load.** At most `maxVisible` routine toasts show; the oldest routine one makes room.
|
||||
* An error is never displaced by a routine toast, and stays until dismissed by default. Showing a
|
||||
* toast identical to one already visible refreshes it rather than stacking a copy. Newest is on top.
|
||||
*
|
||||
* **Readable and accessible.** Timers pause while the pointer is over the region or focus is inside
|
||||
* it (WCAG 2.2 §2.2.1). The region's two live regions are mounted before any toast arrives, so
|
||||
* assistive technology announces what is added: routine toasts politely, errors assertively. Every
|
||||
* toast has a dismiss control named for it, and Escape on a focused toast dismisses it; when the
|
||||
* last one goes, focus returns to where it was before it entered the region.
|
||||
*/
|
||||
export function ToastProvider(props: ToastProviderProps): ReactElement {
|
||||
const maxVisible = Math.max(1, props.maxVisible ?? DEFAULT_MAX_VISIBLE);
|
||||
const [toasts, setToasts] = useState<readonly ToastRecord[]>([]);
|
||||
// The synchronous source of truth: `show` must know at once whether a toast is a duplicate and
|
||||
// what its id is, which a state updater (run later, during render) cannot tell it.
|
||||
const current = useRef<readonly ToastRecord[]>([]);
|
||||
const commit = useCallback((next: readonly ToastRecord[]) => {
|
||||
current.current = next;
|
||||
setToasts(next);
|
||||
}, []);
|
||||
const timers = useRef(new Map<string, ToastTimer>());
|
||||
const paused = useRef(false);
|
||||
const nextId = useRef(0);
|
||||
const focusBeforeRegion = useRef<HTMLElement | null>(null);
|
||||
const regionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const clearTimer = useCallback((id: string) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (timer?.handle !== undefined) {
|
||||
clearTimeout(timer.handle);
|
||||
}
|
||||
timers.current.delete(id);
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(
|
||||
(id: string) => {
|
||||
clearTimer(id);
|
||||
if (current.current.some((toast) => toast.id === id)) {
|
||||
commit(current.current.filter((toast) => toast.id !== id));
|
||||
}
|
||||
},
|
||||
[clearTimer, commit],
|
||||
);
|
||||
|
||||
const startTimer = useCallback(
|
||||
(id: string, remaining: number) => {
|
||||
const existing = timers.current.get(id);
|
||||
if (existing?.handle !== undefined) {
|
||||
clearTimeout(existing.handle);
|
||||
}
|
||||
const timer: ToastTimer = { handle: undefined, remaining, started: Date.now() };
|
||||
if (!paused.current) {
|
||||
timer.handle = setTimeout(() => dismiss(id), remaining);
|
||||
}
|
||||
timers.current.set(id, timer);
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
const show = useCallback(
|
||||
(options: ToastOptions): string => {
|
||||
const tone = options.tone ?? "info";
|
||||
const duration = toastDuration(options);
|
||||
const key = JSON.stringify([tone, options.title, options.description ?? ""]);
|
||||
const duplicate = current.current.find((toast) => toast.key === key);
|
||||
if (duplicate) {
|
||||
// Showing it again restarts its time rather than stacking a copy.
|
||||
if (duplicate.duration !== "persistent") {
|
||||
startTimer(duplicate.id, duplicate.duration);
|
||||
}
|
||||
return duplicate.id;
|
||||
}
|
||||
nextId.current += 1;
|
||||
const id = `ek-toast-${nextId.current}`;
|
||||
const record: ToastRecord = {
|
||||
duration,
|
||||
id,
|
||||
key,
|
||||
title: options.title,
|
||||
tone,
|
||||
...(options.description === undefined ? {} : { description: options.description }),
|
||||
};
|
||||
const next = [record, ...current.current];
|
||||
const routine = next.filter((toast) => toast.tone !== "danger");
|
||||
const overflow = new Set(routine.slice(maxVisible).map((toast) => toast.id));
|
||||
for (const removed of overflow) {
|
||||
clearTimer(removed);
|
||||
}
|
||||
commit(next.filter((toast) => !overflow.has(toast.id)));
|
||||
if (duration !== "persistent") {
|
||||
startTimer(id, duration);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
[clearTimer, commit, maxVisible, startTimer],
|
||||
);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (paused.current) {
|
||||
return;
|
||||
}
|
||||
paused.current = true;
|
||||
const now = Date.now();
|
||||
for (const timer of timers.current.values()) {
|
||||
if (timer.handle !== undefined) {
|
||||
clearTimeout(timer.handle);
|
||||
timer.handle = undefined;
|
||||
timer.remaining = Math.max(0, timer.remaining - (now - timer.started));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
if (!paused.current) {
|
||||
return;
|
||||
}
|
||||
paused.current = false;
|
||||
for (const [id, timer] of [...timers.current.entries()]) {
|
||||
startTimer(id, timer.remaining);
|
||||
}
|
||||
}, [startTimer]);
|
||||
|
||||
// Every timer dies with the provider.
|
||||
useEffect(() => {
|
||||
const active = timers.current;
|
||||
return () => {
|
||||
for (const timer of active.values()) {
|
||||
if (timer.handle !== undefined) {
|
||||
clearTimeout(timer.handle);
|
||||
}
|
||||
}
|
||||
active.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dismissFromRegion = (id: string): void => {
|
||||
const remaining = current.current.filter((toast) => toast.id !== id);
|
||||
dismiss(id);
|
||||
const region = regionRef.current;
|
||||
const focusWasInside =
|
||||
region !== null &&
|
||||
(region.getRootNode() as Document | ShadowRoot).activeElement !== null &&
|
||||
region.contains((region.getRootNode() as Document | ShadowRoot).activeElement);
|
||||
if (!focusWasInside) {
|
||||
return;
|
||||
}
|
||||
// Keep a keyboard user in the region while toasts remain; otherwise return them to where they
|
||||
// were before they entered it, rather than to nowhere.
|
||||
if (remaining.length > 0) {
|
||||
queueMicrotask(() => {
|
||||
region.querySelector<HTMLElement>(".ek-toast__dismiss")?.focus();
|
||||
});
|
||||
} else {
|
||||
const target = focusBeforeRegion.current;
|
||||
focusBeforeRegion.current = null;
|
||||
resume();
|
||||
if (target?.isConnected) {
|
||||
target.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const api = useMemo<ToastApi>(() => ({ dismiss, show }), [dismiss, show]);
|
||||
const routine = toasts.filter((toast) => toast.tone !== "danger");
|
||||
const errors = toasts.filter((toast) => toast.tone === "danger");
|
||||
|
||||
const renderToast = (toast: ToastRecord): ReactElement => (
|
||||
<Toast
|
||||
announce={false}
|
||||
data-toast-id={toast.id}
|
||||
dismissLabel={`Dismiss notification: ${toast.title}`}
|
||||
key={toast.id}
|
||||
onDismiss={() => dismissFromRegion(toast.id)}
|
||||
title={toast.title}
|
||||
tone={toast.tone}
|
||||
>
|
||||
{toast.description}
|
||||
</Toast>
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{props.children}
|
||||
<section
|
||||
aria-label={props.label ?? "Notifications"}
|
||||
className="ek-toast-region"
|
||||
onBlur={(event: FocusEvent<HTMLElement>) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
resume();
|
||||
}
|
||||
}}
|
||||
onFocus={(event: FocusEvent<HTMLElement>) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
focusBeforeRegion.current =
|
||||
event.relatedTarget instanceof HTMLElement ? event.relatedTarget : null;
|
||||
}
|
||||
pause();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
const owner = (event.target as HTMLElement).closest<HTMLElement>("[data-toast-id]");
|
||||
const id = owner?.dataset.toastId;
|
||||
if (id) {
|
||||
event.stopPropagation();
|
||||
dismissFromRegion(id);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={pause}
|
||||
onMouseLeave={() => {
|
||||
const region = regionRef.current;
|
||||
const root = region?.getRootNode() as Document | ShadowRoot | undefined;
|
||||
if (!region || !root?.activeElement || !region.contains(root.activeElement)) {
|
||||
resume();
|
||||
}
|
||||
}}
|
||||
ref={regionRef}
|
||||
>
|
||||
{/* Errors are announced assertively and listed first, so nothing routine can hide one. */}
|
||||
<div aria-live="assertive" className="ek-toast-region__list" role="alert">
|
||||
{errors.map(renderToast)}
|
||||
</div>
|
||||
<div aria-live="polite" className="ek-toast-region__list" role="status">
|
||||
{routine.map(renderToast)}
|
||||
</div>
|
||||
</section>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -131,8 +131,9 @@ describe("Ekklesia elements", () => {
|
||||
// dialog must not escape its isolated root.
|
||||
expect(document.body.querySelector(":scope > [role='dialog']")).toBeNull();
|
||||
|
||||
// Focus is inside the dialog, which handles Escape itself (ADR 0059).
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
||||
dialog?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
||||
});
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,797 @@
|
||||
import { act, type ReactElement, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { matchesRequiredPhrase } from "../src/confirmation.js";
|
||||
import { ERROR_DETAILS_MAX_LENGTH, errorDetailsText } from "../src/feedback.js";
|
||||
import {
|
||||
ConfirmationDialog,
|
||||
type ConfirmationDialogProps,
|
||||
Dialog,
|
||||
ErrorState,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
SkeletonGroup,
|
||||
type ToastApi,
|
||||
ToastProvider,
|
||||
useToasts,
|
||||
} from "../src/index.js";
|
||||
import { EKKLESIA_UI_STYLESHEET } from "../src/styles.js";
|
||||
import { TOAST_MINIMUM_DURATION_MS, toastDuration } from "../src/toasts.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function render(node: ReactElement): void {
|
||||
act(() => root.render(node));
|
||||
}
|
||||
|
||||
function byTestId(id: string): HTMLElement {
|
||||
const element = container.querySelector<HTMLElement>(`[data-testid="${id}"]`);
|
||||
if (!element) {
|
||||
throw new Error(`missing ${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function dialog(): HTMLElement {
|
||||
const element = container.querySelector<HTMLElement>('[role="dialog"]');
|
||||
if (!element) {
|
||||
throw new Error("no dialog");
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function buttonNamed(name: string, scope: ParentNode = container): HTMLButtonElement {
|
||||
const button = [...scope.querySelectorAll("button")].find(
|
||||
(candidate) => (candidate.getAttribute("aria-label") ?? candidate.textContent) === name,
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`no button ${name}`);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
function press(element: Element, key: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key, ...init });
|
||||
act(() => {
|
||||
element.dispatchEvent(event);
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
function type(input: HTMLInputElement, value: string): void {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
act(() => {
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
/** A page with a control that opens a confirmation, so opening, focus return, and the background
|
||||
* can all be observed. */
|
||||
function ConfirmHarness(props: {
|
||||
readonly onConfirm?: () => void;
|
||||
readonly onCancel?: () => void;
|
||||
readonly dialog?: Partial<ConfirmationDialogProps>;
|
||||
}): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<button data-testid="background" type="button">
|
||||
Something behind
|
||||
</button>
|
||||
<button data-testid="opener" onClick={() => setOpen(true)} type="button">
|
||||
Delete display
|
||||
</button>
|
||||
<ConfirmationDialog
|
||||
confirmLabel="Delete display"
|
||||
description="Foyer-1 stops showing content and must be paired again to come back."
|
||||
onCancel={() => {
|
||||
props.onCancel?.();
|
||||
setOpen(false);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
props.onConfirm?.();
|
||||
setOpen(false);
|
||||
}}
|
||||
open={open}
|
||||
title="Delete Foyer-1?"
|
||||
{...props.dialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function openHarness(options: Parameters<typeof ConfirmHarness>[0] = {}): void {
|
||||
render(<ConfirmHarness {...options} />);
|
||||
const opener = byTestId("opener");
|
||||
act(() => opener.focus());
|
||||
act(() => opener.click());
|
||||
}
|
||||
|
||||
describe("ConfirmationDialog", () => {
|
||||
it("is closed until asked, and opens as a named, described modal dialog", () => {
|
||||
render(<ConfirmHarness />);
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
|
||||
act(() => byTestId("opener").click());
|
||||
|
||||
const element = dialog();
|
||||
expect(element.getAttribute("aria-modal")).toBe("true");
|
||||
expect(
|
||||
container.querySelector(`#${element.getAttribute("aria-labelledby")}`)?.textContent,
|
||||
).toBe("Delete Foyer-1?");
|
||||
expect(
|
||||
container.querySelector(`#${element.getAttribute("aria-describedby")}`)?.textContent,
|
||||
).toBe("Foyer-1 stops showing content and must be paired again to come back.");
|
||||
});
|
||||
|
||||
it("confirms with its named action, and cancels with Cancel, without the other firing", () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
openHarness({ onCancel, onConfirm });
|
||||
|
||||
act(() => buttonNamed("Cancel", dialog()).click());
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
|
||||
act(() => byTestId("opener").click());
|
||||
act(() => buttonNamed("Delete display", dialog()).click());
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts a simple confirmation on its action, ready for Enter", () => {
|
||||
openHarness();
|
||||
expect(document.activeElement?.textContent).toBe("Delete display");
|
||||
expect(document.activeElement?.className).toContain("ek-button--primary");
|
||||
});
|
||||
|
||||
it("marks a destructive action by symbol and frame as well as colour, and starts on Cancel", () => {
|
||||
openHarness({ dialog: { tone: "destructive" } });
|
||||
|
||||
const confirm = [...dialog().querySelectorAll("button")].find((button) =>
|
||||
button.textContent?.includes("Delete display"),
|
||||
);
|
||||
expect(confirm?.className).toContain("ek-button--danger");
|
||||
expect(confirm?.querySelector(".ek-confirmation__symbol")?.getAttribute("aria-hidden")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(dialog().className).toContain("ek-confirmation--destructive");
|
||||
expect(dialog().querySelector("h2 .ek-confirmation__symbol")).not.toBeNull();
|
||||
// The safe choice has focus, so a stray Enter cancels rather than destroys.
|
||||
expect(document.activeElement?.textContent).toBe("Cancel");
|
||||
expect(EKKLESIA_UI_STYLESHEET).toContain(".ek-confirmation__symbol::before");
|
||||
});
|
||||
|
||||
it("requires the exact phrase before a typed confirmation can be confirmed", () => {
|
||||
const onConfirm = vi.fn();
|
||||
openHarness({ dialog: { requiredPhrase: "Foyer-1", tone: "destructive" }, onConfirm });
|
||||
const input = dialog().querySelector("input") as HTMLInputElement;
|
||||
const confirm = [...dialog().querySelectorAll("button")].find((button) =>
|
||||
button.textContent?.includes("Delete display"),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
// Focus starts at the field, which is labelled with the phrase it wants.
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.labels?.[0]?.textContent).toBe("Type Foyer-1 to confirm");
|
||||
expect(input.getAttribute("autocapitalize")).toBe("off");
|
||||
expect(input.getAttribute("spellcheck")).toBe("false");
|
||||
expect(confirm.disabled).toBe(true);
|
||||
|
||||
type(input, "foyer-1");
|
||||
expect(confirm.disabled).toBe(true);
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
act(() => {
|
||||
(input.form as HTMLFormElement).dispatchEvent(
|
||||
new Event("submit", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
|
||||
type(input, " Foyer-1 ");
|
||||
expect(confirm.disabled).toBe(false);
|
||||
expect(input.getAttribute("aria-invalid")).toBeNull();
|
||||
// Enter in the field submits once it matches.
|
||||
act(() => {
|
||||
(input.form as HTMLFormElement).dispatchEvent(
|
||||
new Event("submit", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets a typed phrase when it closes", () => {
|
||||
openHarness({ dialog: { requiredPhrase: "DELETE" } });
|
||||
type(dialog().querySelector("input") as HTMLInputElement, "DELETE");
|
||||
act(() => buttonNamed("Cancel", dialog()).click());
|
||||
|
||||
act(() => byTestId("opener").click());
|
||||
|
||||
expect((dialog().querySelector("input") as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("matches a phrase exactly, forgiving only surrounding whitespace and Unicode composition", () => {
|
||||
expect(matchesRequiredPhrase("DELETE", "DELETE")).toBe(true);
|
||||
expect(matchesRequiredPhrase(" DELETE\n", "DELETE")).toBe(true);
|
||||
expect(matchesRequiredPhrase("delete", "DELETE")).toBe(false);
|
||||
expect(matchesRequiredPhrase("DELET", "DELETE")).toBe(false);
|
||||
expect(matchesRequiredPhrase("DELETE DELETE", "DELETE")).toBe(false);
|
||||
expect(matchesRequiredPhrase("Café", "Café")).toBe(true);
|
||||
});
|
||||
|
||||
it("closes on Escape and returns focus to the control that opened it", () => {
|
||||
const onCancel = vi.fn();
|
||||
openHarness({ onCancel });
|
||||
|
||||
const event = press(dialog(), "Escape");
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(document.activeElement).toBe(byTestId("opener"));
|
||||
});
|
||||
|
||||
it("does not close on Escape or Cancel while the confirmed action is in progress", () => {
|
||||
const onCancel = vi.fn();
|
||||
openHarness({ dialog: { busy: true }, onCancel });
|
||||
|
||||
press(dialog(), "Escape");
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
expect(buttonNamed("Cancel", dialog()).disabled).toBe(true);
|
||||
expect(dialog().querySelector("form")?.getAttribute("aria-busy")).toBe("true");
|
||||
});
|
||||
|
||||
it("keeps Tab and Shift+Tab inside the dialog", () => {
|
||||
openHarness({ dialog: { requiredPhrase: "DELETE" } });
|
||||
const input = dialog().querySelector("input") as HTMLInputElement;
|
||||
type(input, "DELETE");
|
||||
const cancel = buttonNamed("Cancel", dialog());
|
||||
const confirm = buttonNamed("Delete display", dialog());
|
||||
|
||||
act(() => confirm.focus());
|
||||
expect(press(confirm, "Tab").defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
expect(press(input, "Tab", { shiftKey: true }).defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(confirm);
|
||||
|
||||
// Between inner controls, the browser moves focus itself.
|
||||
act(() => input.focus());
|
||||
expect(press(input, "Tab").defaultPrevented).toBe(false);
|
||||
expect(cancel).toBeTruthy();
|
||||
});
|
||||
|
||||
it("blocks the page behind: the scrim takes the pointer and nothing behind is in the tab loop", () => {
|
||||
openHarness();
|
||||
const scrim = dialog().parentElement as HTMLElement;
|
||||
expect(scrim.className).toBe("ek-dialog__scrim");
|
||||
|
||||
const pointer = new MouseEvent("pointerdown", { bubbles: true, cancelable: true });
|
||||
act(() => {
|
||||
scrim.dispatchEvent(pointer);
|
||||
});
|
||||
expect(pointer.defaultPrevented).toBe(true);
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
expect(dialog().contains(byTestId("background"))).toBe(false);
|
||||
|
||||
// Tab from the last control wraps inside rather than reaching "Something behind".
|
||||
const last = buttonNamed("Delete display", dialog());
|
||||
act(() => last.focus());
|
||||
press(last, "Tab");
|
||||
expect(document.activeElement).not.toBe(byTestId("background"));
|
||||
expect(dialog().contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
it("stops Escape at the dialog, so a drawer or menu around it stays open", () => {
|
||||
const outer = vi.fn();
|
||||
render(
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: a test stand-in for a drawer that closes on Escape.
|
||||
<div
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
outer();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ConfirmHarness />
|
||||
</div>,
|
||||
);
|
||||
act(() => byTestId("opener").click());
|
||||
|
||||
press(dialog(), "Escape");
|
||||
|
||||
expect(outer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is operable with the keyboard alone", () => {
|
||||
const onConfirm = vi.fn();
|
||||
openHarness({ dialog: { requiredPhrase: "DELETE" }, onConfirm });
|
||||
const input = document.activeElement as HTMLInputElement;
|
||||
|
||||
type(input, "DELETE");
|
||||
act(() => {
|
||||
(input.form as HTMLFormElement).requestSubmit();
|
||||
});
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dialog", () => {
|
||||
it("traps focus, describes itself, and returns focus when it closes", () => {
|
||||
function Harness(): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button data-testid="open" onClick={() => setOpen(true)} type="button">
|
||||
Open
|
||||
</button>
|
||||
<Dialog
|
||||
actions={
|
||||
<button onClick={() => setOpen(false)} type="button">
|
||||
Done
|
||||
</button>
|
||||
}
|
||||
description="Details about the dialog."
|
||||
onDismiss={() => setOpen(false)}
|
||||
open={open}
|
||||
title="Information"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
act(() => byTestId("open").focus());
|
||||
act(() => byTestId("open").click());
|
||||
|
||||
expect(document.activeElement).toBe(dialog());
|
||||
expect(
|
||||
container.querySelector(`#${dialog().getAttribute("aria-describedby")}`)?.textContent,
|
||||
).toBe("Details about the dialog.");
|
||||
const done = buttonNamed("Done");
|
||||
act(() => done.focus());
|
||||
press(done, "Tab");
|
||||
expect(document.activeElement).toBe(done);
|
||||
|
||||
act(() => done.click());
|
||||
expect(document.activeElement).toBe(byTestId("open"));
|
||||
});
|
||||
|
||||
it("ignores Escape when the caller says dismissing is not safe", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<Dialog dismissible={false} onDismiss={onDismiss} open title="Saving" />);
|
||||
|
||||
press(dialog(), "Escape");
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function ToastHarness(props: { readonly onApi: (api: ToastApi) => void }): null {
|
||||
props.onApi(useToasts());
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderToasts(options: { readonly maxVisible?: number } = {}): ToastApi {
|
||||
let api: ToastApi | undefined;
|
||||
render(
|
||||
<ToastProvider {...options}>
|
||||
<ToastHarness
|
||||
onApi={(value) => {
|
||||
api = value;
|
||||
}}
|
||||
/>
|
||||
</ToastProvider>,
|
||||
);
|
||||
if (!api) {
|
||||
throw new Error("no toast api");
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
function toastTitles(): string[] {
|
||||
return [...container.querySelectorAll(".ek-toast .ek-toast__title")].map(
|
||||
(title) => title.textContent?.replace(/^\S+\s/u, "") ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
describe("toasts", () => {
|
||||
it("renders a top-center region whose live regions exist before any toast arrives", () => {
|
||||
renderToasts();
|
||||
|
||||
const region = container.querySelector(".ek-toast-region");
|
||||
expect(region?.tagName).toBe("SECTION");
|
||||
expect(region?.getAttribute("aria-label")).toBe("Notifications");
|
||||
expect(region?.querySelector('[role="status"][aria-live="polite"]')).not.toBeNull();
|
||||
expect(region?.querySelector('[role="alert"][aria-live="assertive"]')).not.toBeNull();
|
||||
expect(EKKLESIA_UI_STYLESHEET).toMatch(/\.ek-toast-region \{[^}]*position: fixed;[^}]*top:/su);
|
||||
});
|
||||
|
||||
it("shows each severity with its symbol, errors assertively and the rest politely", () => {
|
||||
vi.useFakeTimers();
|
||||
const api = renderToasts();
|
||||
|
||||
act(() => {
|
||||
api.show({ title: "Saved", tone: "success" });
|
||||
api.show({ title: "Heads up", tone: "info" });
|
||||
api.show({ title: "Storage is nearly full", tone: "warning" });
|
||||
api.show({ title: "The display could not be revoked", tone: "danger" });
|
||||
});
|
||||
|
||||
const polite = container.querySelector('[aria-live="polite"]') as HTMLElement;
|
||||
const assertive = container.querySelector('[aria-live="assertive"]') as HTMLElement;
|
||||
expect(assertive.textContent).toContain("The display could not be revoked");
|
||||
expect(polite.textContent).not.toContain("could not be revoked");
|
||||
for (const tone of ["success", "info", "warning", "danger"]) {
|
||||
const toast = container.querySelector(`.ek-toast--${tone}`);
|
||||
expect(toast, tone).not.toBeNull();
|
||||
// Its own role would nest one live region in another.
|
||||
expect(toast?.getAttribute("role"), tone).toBeNull();
|
||||
expect(toast?.querySelector('[aria-hidden="true"]')?.textContent?.trim(), tone).not.toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("dismisses a routine toast on its own once there has been time to read it", () => {
|
||||
vi.useFakeTimers();
|
||||
const api = renderToasts();
|
||||
|
||||
act(() => {
|
||||
api.show({ title: "Saved", tone: "success" });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(TOAST_MINIMUM_DURATION_MS - 1);
|
||||
});
|
||||
expect(toastTitles()).toEqual(["Saved"]);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(toastTitles()).toEqual([]);
|
||||
});
|
||||
|
||||
it("never times a toast out in under three seconds, and gives longer messages longer", () => {
|
||||
const tooShort = toastDuration({ duration: 500, title: "Saved" }) as number;
|
||||
expect(tooShort).toBeGreaterThanOrEqual(TOAST_MINIMUM_DURATION_MS);
|
||||
expect(tooShort).toBeLessThan(TOAST_MINIMUM_DURATION_MS + 1000);
|
||||
const short = toastDuration({ title: "Saved", tone: "success" }) as number;
|
||||
const long = toastDuration({
|
||||
description:
|
||||
"The timeline was published to every screen in the Sanctuary and Foyer groups, and the next slide starts in a minute.",
|
||||
title: "Published",
|
||||
tone: "success",
|
||||
}) as number;
|
||||
expect(short).toBeGreaterThanOrEqual(TOAST_MINIMUM_DURATION_MS);
|
||||
expect(long).toBeGreaterThan(short);
|
||||
expect(toastDuration({ title: "Careful", tone: "warning" })).toBeGreaterThan(short);
|
||||
});
|
||||
|
||||
it("keeps an error until it is dismissed, unless its caller gives it a duration", () => {
|
||||
vi.useFakeTimers();
|
||||
const api = renderToasts();
|
||||
|
||||
act(() => {
|
||||
api.show({ title: "The display could not be revoked", tone: "danger" });
|
||||
api.show({ duration: 4000, title: "Enter a name", tone: "danger" });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(toastTitles()).toEqual(["The display could not be revoked"]);
|
||||
expect(toastDuration({ title: "x", tone: "danger" })).toBe("persistent");
|
||||
});
|
||||
|
||||
it("dismisses on its named dismiss control", () => {
|
||||
vi.useFakeTimers();
|
||||
const api = renderToasts();
|
||||
act(() => {
|
||||
api.show({ title: "The display could not be revoked", tone: "danger" });
|
||||
});
|
||||
|
||||
act(() => buttonNamed("Dismiss notification: The display could not be revoked").click());
|
||||
|
||||
expect(toastTitles()).toEqual([]);
|
||||
});
|
||||
|
||||
it("dismisses programmatically by the id it returned", () => {
|
||||
vi.useFakeTimers();
|
||||
const api = renderToasts();
|
||||
let id = "";
|
||||
act(() => {
|
||||
id = api.show({ title: "Uploading", tone: "info" });
|
||||
});
|
||||
expect(id).toMatch(/^ek-toast-/u);
|
||||
|
||||
act(() => api.dismiss(id));
|
||||
|
||||
expect(toastTitles()).toEqual([]);
|
||||
});
|
||||
|
||||
it("dismisses the focused toast on Escape and returns focus where it was", () => {
|
||||
vi.useFakeTimers();
|
||||
let api: ToastApi | undefined;
|
||||
render(
|
||||
<ToastProvider>
|
||||
<button data-testid="work" type="button">
|
||||
Work
|
||||
</button>
|
||||
<ToastHarness
|
||||
onApi={(value) => {
|
||||
api = value;
|
||||
}}
|
||||
/>
|
||||
</ToastProvider>,
|
||||
);
|
||||
act(() => {
|
||||
api?.show({ title: "Saved", tone: "success" });
|
||||
});
|
||||
const work = byTestId("work");
|
||||
act(() => work.focus());
|
||||
const dismiss = buttonNamed("Dismiss notification: Saved");
|
||||
act(() => {
|
||||
dismiss.dispatchEvent(new FocusEvent("focusin", { bubbles: true, relatedTarget: work }));
|
||||
dismiss.focus();
|
||||
});
|
||||
|
||||
press(dismiss, "Escape");
|
||||
|
||||
expect(toastTitles()).toEqual([]);
|
||||
expect(document.activeElement).toBe(work);
|
||||
});
|
||||
|
||||
it("stacks newest first, refreshes a duplicate, and never lets routine toasts displace an error", () => {
|
||||
vi.useFakeTimers();
|
||||
const api = renderToasts({ maxVisible: 2 });
|
||||
|
||||
act(() => {
|
||||
api.show({ title: "Storage is unavailable", tone: "danger" });
|
||||
api.show({ title: "One", tone: "info" });
|
||||
api.show({ title: "Two", tone: "info" });
|
||||
api.show({ title: "Three", tone: "info" });
|
||||
api.show({ title: "Three", tone: "info" });
|
||||
});
|
||||
|
||||
// Errors are listed first; then routine toasts, newest first, at most two of them.
|
||||
expect(toastTitles()).toEqual(["Storage is unavailable", "Three", "Two"]);
|
||||
expect(container.querySelectorAll(".ek-toast")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("pauses while the pointer is over it, so a toast being read does not vanish", () => {
|
||||
vi.useFakeTimers();
|
||||
const api = renderToasts();
|
||||
act(() => {
|
||||
api.show({ title: "Saved", tone: "success" });
|
||||
});
|
||||
const region = container.querySelector(".ek-toast-region") as HTMLElement;
|
||||
|
||||
act(() => {
|
||||
region.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
expect(toastTitles()).toEqual(["Saved"]);
|
||||
|
||||
act(() => {
|
||||
region.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
expect(toastTitles()).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears every timer when the provider goes away", () => {
|
||||
vi.useFakeTimers();
|
||||
const api = renderToasts();
|
||||
act(() => {
|
||||
api.show({ title: "One", tone: "info" });
|
||||
api.show({ title: "Two", tone: "warning" });
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(2);
|
||||
|
||||
act(() => root.unmount());
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
it("is per React tree: outside a provider there is no toast API to reach for", () => {
|
||||
function Orphan(): null {
|
||||
useToasts();
|
||||
return null;
|
||||
}
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
expect(() => render(<Orphan />)).toThrow("useToasts must be used inside a ToastProvider.");
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ErrorState", () => {
|
||||
it("leads with a plain message, and shows no disclosure when there is no detail", () => {
|
||||
render(<ErrorState description="Check the connection, then retry." title="Could not load" />);
|
||||
|
||||
const element = container.querySelector(".ek-message--error") as HTMLElement;
|
||||
expect(element.getAttribute("role")).toBe("alert");
|
||||
expect(element.querySelector(".ek-message__title")?.textContent).toBe("Could not load");
|
||||
expect(element.querySelector("details")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps technical detail and a reference collapsed behind a disclosure", () => {
|
||||
render(
|
||||
<ErrorState
|
||||
details={"STORAGE_PROVIDER_UNAVAILABLE\nThe object store did not answer within 5 s."}
|
||||
reference="req-7f3a"
|
||||
title="The image could not be uploaded"
|
||||
/>,
|
||||
);
|
||||
|
||||
const details = container.querySelector("details") as HTMLDetailsElement;
|
||||
expect(details.open).toBe(false);
|
||||
expect(details.querySelector("summary")?.textContent).toBe("Technical details");
|
||||
expect(details.querySelector("pre")?.textContent).toBe(
|
||||
"STORAGE_PROVIDER_UNAVAILABLE\nThe object store did not answer within 5 s.",
|
||||
);
|
||||
expect(details.querySelector("code")?.textContent).toBe("req-7f3a");
|
||||
});
|
||||
|
||||
it("offers recovery and remedy actions beside the message", () => {
|
||||
const retry = vi.fn();
|
||||
render(
|
||||
<ErrorState
|
||||
actions={
|
||||
<>
|
||||
<button onClick={retry} type="button">
|
||||
Retry
|
||||
</button>
|
||||
<a href="/settings">Open storage settings</a>
|
||||
</>
|
||||
}
|
||||
title="Could not load"
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => buttonNamed("Retry").click());
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector(".ek-message__actions a")?.getAttribute("href")).toBe(
|
||||
"/settings",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders an arbitrary server string as inert, bounded text", () => {
|
||||
const hostile = `<img src=x onerror="alert(1)"><script>steal()</script>\u0007\u001b[31m${"x".repeat(5000)}`;
|
||||
render(<ErrorState details={hostile} title="Something went wrong" />);
|
||||
|
||||
const pre = container.querySelector("pre") as HTMLElement;
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
expect(pre.textContent?.startsWith('<img src=x onerror="alert(1)">')).toBe(true);
|
||||
expect(pre.textContent).not.toContain("\u0007");
|
||||
expect(pre.textContent?.length).toBe(ERROR_DETAILS_MAX_LENGTH + 1);
|
||||
expect(errorDetailsText(" line one\n\tline two ")).toBe("line one\n\tline two");
|
||||
});
|
||||
|
||||
it("can be part of a page's initial content without being announced", () => {
|
||||
render(<ErrorState announce={false} title="Not available" />);
|
||||
|
||||
expect(container.querySelector(".ek-message--error")?.getAttribute("role")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Skeleton", () => {
|
||||
it("is a busy, labelled region announced once, with every placeholder hidden from it", () => {
|
||||
render(
|
||||
<SkeletonGroup label="Loading displays">
|
||||
<Skeleton shape="circle" width="2rem" />
|
||||
<Skeleton width="60%" />
|
||||
<Skeleton shape="block" />
|
||||
</SkeletonGroup>,
|
||||
);
|
||||
|
||||
const group = container.querySelector(".ek-skeleton-group") as HTMLElement;
|
||||
expect(group.getAttribute("role")).toBe("status");
|
||||
expect(group.getAttribute("aria-busy")).toBe("true");
|
||||
expect(container.querySelector(`#${group.getAttribute("aria-labelledby")}`)?.textContent).toBe(
|
||||
"Loading displays",
|
||||
);
|
||||
const placeholders = [...group.querySelectorAll(".ek-skeleton")] as HTMLElement[];
|
||||
expect(placeholders).toHaveLength(3);
|
||||
for (const placeholder of placeholders) {
|
||||
expect(placeholder.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(placeholder.textContent).toBe("");
|
||||
}
|
||||
expect(placeholders[0]?.style.height).toBe("2rem");
|
||||
// No percentage, no progress: there is none to report.
|
||||
expect(group.textContent).toBe("Loading displays");
|
||||
});
|
||||
|
||||
it("stops moving for a person who asked for reduced motion", () => {
|
||||
const reduced = EKKLESIA_UI_STYLESHEET.slice(
|
||||
EKKLESIA_UI_STYLESHEET.indexOf("@media (prefers-reduced-motion: reduce)"),
|
||||
);
|
||||
expect(reduced).toMatch(/\.ek-skeleton[,\s]/u);
|
||||
expect(reduced).toContain("animation: none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PageHeader", () => {
|
||||
it("names the page with its one h1 and keeps page actions beside it", () => {
|
||||
render(<PageHeader actions={<button type="button">Add display</button>} title="Displays" />);
|
||||
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Displays");
|
||||
expect(container.querySelector(".ek-page-header__actions button")?.textContent).toBe(
|
||||
"Add display",
|
||||
);
|
||||
expect(container.querySelector(".ek-page-header__back")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts a Back link above the title when Back is a real place", () => {
|
||||
const onBack = vi.fn();
|
||||
render(
|
||||
<PageHeader
|
||||
back={{ href: "/signage", label: "Back to Signage", onBack }}
|
||||
title="Add display"
|
||||
/>,
|
||||
);
|
||||
|
||||
const back = container.querySelector(".ek-page-header__back") as HTMLAnchorElement;
|
||||
expect(back.tagName).toBe("A");
|
||||
expect(back.getAttribute("href")).toBe("/signage");
|
||||
expect(back.textContent).toBe("←Back to Signage");
|
||||
expect(back.querySelector('[aria-hidden="true"]')?.textContent).toBe("←");
|
||||
const copy = back.parentElement as HTMLElement;
|
||||
expect([...copy.children].indexOf(back)).toBeLessThan(
|
||||
[...copy.children].indexOf(copy.querySelector("h1") as Element),
|
||||
);
|
||||
|
||||
const click = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true });
|
||||
act(() => {
|
||||
back.dispatchEvent(click);
|
||||
});
|
||||
expect(click.defaultPrevented).toBe(true);
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
|
||||
const newTab = new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
cancelable: true,
|
||||
ctrlKey: true,
|
||||
});
|
||||
act(() => {
|
||||
back.dispatchEvent(newTab);
|
||||
});
|
||||
expect(newTab.defaultPrevented).toBe(false);
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders Back as a button when it has no address", () => {
|
||||
const onBack = vi.fn();
|
||||
render(<PageHeader back={{ label: "Back", onBack }} title="Details" />);
|
||||
|
||||
const back = container.querySelector(".ek-page-header__back") as HTMLButtonElement;
|
||||
expect(back.tagName).toBe("BUTTON");
|
||||
expect(back.type).toBe("button");
|
||||
act(() => back.click());
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Generated
+12
@@ -407,6 +407,9 @@ importers:
|
||||
'@ekklesia/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
lucide-react:
|
||||
specifier: 1.48.0
|
||||
version: 1.48.0([email protected])
|
||||
react:
|
||||
specifier: 19.2.8
|
||||
version: 19.2.8
|
||||
@@ -2401,6 +2404,11 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@@ -4369,6 +4377,10 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
+67
-1
@@ -6,7 +6,9 @@
|
||||
# - its module storage mount point, /var/lib/ekklesia/modules, exists, is root-owned, and is empty;
|
||||
# - EKKLESIA_MODULES_DIR names that directory, in the image configuration and in a running process;
|
||||
# - a checkout with a populated modules/ directory builds the same image: the runtime file system
|
||||
# of both builds is identical, and no module from that directory enters it.
|
||||
# of both builds is identical, and no module from that directory enters it;
|
||||
# - the hardened verifier keeps /tmp noexec while a dedicated bounded execution scratch loads and
|
||||
# runs the real sharp 0.35.5/libvips 1.3.4 native proof package.
|
||||
#
|
||||
# bash scripts/check-image.sh builds both images from this checkout
|
||||
# EKKLESIA_CHECK_IMAGE=<image> bash scripts/check-image.sh checks one existing image's contents
|
||||
@@ -103,6 +105,7 @@ context_copy() {
|
||||
tag_base="ekklesia-ci:${GITHUB_SHA:-local}-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-"
|
||||
generic_image="${tag_base}generic-$$"
|
||||
populated_image="${tag_base}populated-$$"
|
||||
builder_image="${tag_base}native-proof-builder-$$"
|
||||
|
||||
# build_image <context> <tag>
|
||||
build_image() {
|
||||
@@ -143,4 +146,67 @@ else
|
||||
echo "note the two builds' layers differ (a cold build cache); their file systems do not"
|
||||
fi
|
||||
|
||||
step "Proving native package verification with /tmp noexec and a dedicated execution scratch"
|
||||
built_images+=("$builder_image")
|
||||
DOCKER_BUILDKIT=1 docker build --quiet --file "$work/generic/deploy/Dockerfile" \
|
||||
--target build --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||
--label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
|
||||
--tag "$builder_image" "$work/generic" >/dev/null || fail "the native proof builder did not build."
|
||||
mkdir "$work/native-package"
|
||||
package_archive="$({
|
||||
docker run --rm --network none --user "$(id -u):$(id -g)" \
|
||||
-v "$work/native-package:/proof" --entrypoint node "$builder_image" \
|
||||
packages/module-pack/tests/support/pack-native-verifier-proof.mjs /proof
|
||||
} | tail -1)" || fail "the real native proof package did not build."
|
||||
[[ "$package_archive" == native-proof-0.1.0-linux-x64-glibc.tar.gz ]] ||
|
||||
fail "the native proof builder returned an unexpected archive."
|
||||
|
||||
docker run --rm --network none --read-only \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-e "PROOF_ARCHIVE=$package_archive" -v "$work/native-package:/package:ro" \
|
||||
--entrypoint sh "$generic_image" -ec '
|
||||
fail() { echo "check-image: $*" >&2; exit 1; }
|
||||
verifier() {
|
||||
EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||
node core/dist/cli/verify-module-package.js "/package/$PROOF_ARCHIVE" \
|
||||
--sha256-file "/package/$PROOF_ARCHIVE.sha256" "$@"
|
||||
}
|
||||
scratch_failure() {
|
||||
if output="$(EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR="$1" \
|
||||
node core/dist/cli/verify-module-package.js "/package/$PROOF_ARCHIVE" \
|
||||
--sha256-file "/package/$PROOF_ARCHIVE.sha256" 2>&1)"; then
|
||||
fail "the verifier accepted unusable execution scratch $1."
|
||||
fi
|
||||
printf "%s\n" "$output" | grep -Fq "[MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE]" ||
|
||||
fail "the verifier did not identify unusable execution scratch $1."
|
||||
}
|
||||
|
||||
grep -Eq " /tmp .*noexec" /proc/mounts || fail "/tmp is not mounted noexec."
|
||||
verifier --extract-to /tmp/noexec-installed >/tmp/noexec-verify.log
|
||||
if env -i /usr/local/bin/node --input-type=module --eval \
|
||||
"await import(\"file:///tmp/noexec-installed/server/index.js\");" \
|
||||
>/tmp/noexec-load.log 2>&1; then
|
||||
fail "the native addon unexpectedly loaded from noexec /tmp."
|
||||
fi
|
||||
rm -rf /tmp/noexec-installed
|
||||
scratch_failure /tmp
|
||||
scratch_failure /missing-verifier-scratch
|
||||
scratch_failure /package
|
||||
|
||||
verifier --extract-to /run/ekklesia-verifier/installed >/tmp/native-verify.log
|
||||
env -i /usr/local/bin/node --input-type=module --eval \
|
||||
"const m=await import(\"file:///run/ekklesia-verifier/installed/server/index.js\"); const actual=await m.proof(); const expected={pixels:[255,0,0,255,0,0,255,0,0,255,0,0],png:{format:\"png\",height:3,width:5},resized:{channels:3,height:2,width:2}}; if(JSON.stringify(actual)!==JSON.stringify(expected)) throw new Error(\"sharp proof mismatch\");"
|
||||
rm -rf /run/ekklesia-verifier/installed
|
||||
|
||||
verifier >/tmp/native-verify-a.log 2>&1 & first=$!
|
||||
verifier >/tmp/native-verify-b.log 2>&1 & second=$!
|
||||
wait "$first" || { cat /tmp/native-verify-a.log >&2; fail "first concurrent verification failed."; }
|
||||
wait "$second" || { cat /tmp/native-verify-b.log >&2; fail "second concurrent verification failed."; }
|
||||
[ -z "$(find /run/ekklesia-verifier -mindepth 1 -print -quit)" ] ||
|
||||
fail "the verifier left execution scratch behind."
|
||||
' || fail "the hardened native verifier proof failed."
|
||||
echo "ok /tmp stayed noexec; native verification, sharp execution, rejection, concurrency, and cleanup passed"
|
||||
|
||||
echo "Generic platform image check passed."
|
||||
|
||||
+14
-2
@@ -5,6 +5,11 @@
|
||||
# bash scripts/ci-sweep.sh --stale [--max-age-seconds N] any CI stack older than N (default 3 h)
|
||||
# bash scripts/ci-sweep.sh --job this job's own stacks, at any age
|
||||
#
|
||||
# `--stale --prefix P` narrows the sweep to stacks whose names begin with P, which must itself begin
|
||||
# with the CI prefix: it can only ever narrow a sweep, never widen one. scripts/test-ci-sweep.sh
|
||||
# confines its short thresholds with it, because the daemon is shared and another job's live stack
|
||||
# may be on it right now.
|
||||
#
|
||||
# Every CI script already removes its own stack in an EXIT trap, but a trap never runs when the runner
|
||||
# kills the job container: a cancelled or superseded job (the server honours `concurrency`), or
|
||||
# the runner's own time limit. So each workflow job that starts stacks ends with `--job` in an
|
||||
@@ -31,9 +36,10 @@ source "$repository_root/scripts/lib/ci-project.sh"
|
||||
compose_label="com.docker.compose.project"
|
||||
mode=""
|
||||
max_age_seconds="${EKKLESIA_CI_SWEEP_MAX_AGE_SECONDS:-10800}"
|
||||
narrow_prefix=""
|
||||
|
||||
usage() {
|
||||
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] | --job" >&2
|
||||
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] [--prefix P] | --job" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
@@ -48,11 +54,17 @@ while (($# > 0)); do
|
||||
max_age_seconds="$2"
|
||||
shift
|
||||
;;
|
||||
--prefix)
|
||||
[[ "${2:-}" == "$EKKLESIA_CI_PREFIX"?* ]] || usage
|
||||
narrow_prefix="$2"
|
||||
shift
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[[ -n "$mode" ]] || usage
|
||||
[[ -z "$narrow_prefix" || "$mode" == stale ]] || usage
|
||||
|
||||
if [[ "$mode" == job ]]; then
|
||||
[[ -n "${GITHUB_RUN_ID:-}" ]] || {
|
||||
@@ -62,7 +74,7 @@ if [[ "$mode" == job ]]; then
|
||||
prefix="$(ci_job_prefix)"
|
||||
cutoff=""
|
||||
else
|
||||
prefix="$EKKLESIA_CI_PREFIX"
|
||||
prefix="${narrow_prefix:-$EKKLESIA_CI_PREFIX}"
|
||||
cutoff="$(($(date +%s) - max_age_seconds))"
|
||||
fi
|
||||
|
||||
|
||||
@@ -187,10 +187,14 @@ check(
|
||||
);
|
||||
|
||||
// --- A module UI, imported from the API's origin -------------------------------------------------
|
||||
const moduleButton = "document.querySelector('[data-testid^=\"select-\"]')";
|
||||
// The first module link — never the Overview, which is the Dashboard's own landing page (ADR 0057).
|
||||
const moduleButton =
|
||||
'document.querySelector(\'[data-testid^="select-"]:not([data-testid="select-overview"])\')';
|
||||
const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
|
||||
let openedModuleId;
|
||||
if (hasModule) {
|
||||
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
|
||||
openedModuleId = moduleId;
|
||||
await evaluate(`${moduleButton}.click()`);
|
||||
const mounted = await waitFor(
|
||||
`${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
|
||||
@@ -224,6 +228,20 @@ if (hasModule) {
|
||||
streams.every((response) => response.status === 200),
|
||||
`every realtime stream it opened was accepted (${streams.length} opened)`,
|
||||
);
|
||||
|
||||
// A refresh stays on the module's own page: the proxy forwards the deep link, the API serves the
|
||||
// Dashboard page there, and the shell mounts the same module again (ADR 0057).
|
||||
const moduleUrl = await evaluate("location.href");
|
||||
check(
|
||||
moduleUrl === `${dashboardOrigin}/${moduleId}`,
|
||||
"opening the module put its own address in the address bar",
|
||||
);
|
||||
await navigate(moduleUrl);
|
||||
const remounted = await waitFor(
|
||||
`location.href === ${JSON.stringify(moduleUrl)} && ${selector(`select-${moduleId}`)}?.getAttribute("aria-current") === "page" && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
|
||||
"the module UI to mount again after a refresh",
|
||||
);
|
||||
check(remounted, `refreshing ${moduleUrl} reopens the ${moduleId} module`);
|
||||
}
|
||||
// A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a
|
||||
// module unmounting, a navigation) is not.
|
||||
@@ -266,9 +284,74 @@ if (displayPath === null) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sign out on the Dashboard -------------------------------------------------------------------
|
||||
// --- The phone layout: the same navigation, in a drawer (ADR 0058) ------------------------------
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 2,
|
||||
height: 844,
|
||||
mobile: true,
|
||||
width: 390,
|
||||
});
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
await waitFor(selector("host-logout"), "the sign-out control");
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("mobile-navigation-trigger")} && !${selector("host-sidebar")}`,
|
||||
"the phone navigation trigger",
|
||||
),
|
||||
"a phone-sized window shows the navigation trigger instead of the sidebar",
|
||||
);
|
||||
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`document.activeElement === ${selector("navigation-drawer-close")} && ${selector("host-main-region")}.inert`,
|
||||
"focus inside the opened drawer",
|
||||
),
|
||||
"the drawer opens as a modal, with focus inside it and the page inert",
|
||||
);
|
||||
await evaluate(
|
||||
`document.activeElement.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }))`,
|
||||
);
|
||||
check(
|
||||
await waitFor(
|
||||
`!${selector("navigation-drawer")} && document.activeElement === ${selector("mobile-navigation-trigger")}`,
|
||||
"the drawer to close on Escape",
|
||||
),
|
||||
"Escape closes the drawer and returns focus to its trigger",
|
||||
);
|
||||
if (openedModuleId) {
|
||||
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
|
||||
await waitFor(selector(`select-${openedModuleId}`), "the module in the drawer");
|
||||
await evaluate(`${selector(`select-${openedModuleId}`)}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(`/${openedModuleId}`)} && !${selector("navigation-drawer")} && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-error")}`,
|
||||
"the module chosen from the drawer to open",
|
||||
),
|
||||
`choosing ${openedModuleId} in the drawer closes it and opens the module at its own address`,
|
||||
);
|
||||
}
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 1,
|
||||
height: 1080,
|
||||
mobile: false,
|
||||
width: 1920,
|
||||
});
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("host-sidebar")} && !${selector("mobile-navigation-trigger")}${
|
||||
openedModuleId
|
||||
? ` && ${selector(`select-${openedModuleId}`)}?.getAttribute("aria-current") === "page"`
|
||||
: ""
|
||||
}`,
|
||||
"the sidebar after widening the window",
|
||||
),
|
||||
"widening the window brings the sidebar back on the same page",
|
||||
);
|
||||
|
||||
// --- Sign out on the Dashboard, from the account menu at the bottom of the sidebar ---------------
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
await waitFor(selector("host-account-menu"), "the account control");
|
||||
await evaluate(`${selector("host-account-menu")}.click()`);
|
||||
await waitFor(selector("host-logout"), "the sign-out item in the account menu");
|
||||
await evaluate(`${selector("host-logout")}.click()`);
|
||||
check(
|
||||
await waitFor(selector("login-email"), "the sign-in form after sign-out"),
|
||||
|
||||
@@ -35,6 +35,10 @@ function required(name) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function optional(name) {
|
||||
return process.env[name] || "";
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function check(condition, description) {
|
||||
@@ -84,6 +88,27 @@ async function edgeChecks() {
|
||||
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
|
||||
);
|
||||
|
||||
// Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). No module
|
||||
// is installed yet at this point; a module's own address is reloaded in a real browser later.
|
||||
for (const deepLink of ["/settings", "/settings/displays/add", "/account"]) {
|
||||
const deepPage = await fetch(`${dashboardOrigin}${deepLink}`);
|
||||
check(
|
||||
deepPage.status === 200 &&
|
||||
deepPage.headers.get("etag") === page.headers.get("etag") &&
|
||||
(await deepPage.text()) === html,
|
||||
`GET ${deepLink} through the proxy is the same Dashboard page`,
|
||||
);
|
||||
}
|
||||
// A top-level name shaped like a module id reaches the API, which knows the installed modules and
|
||||
// answers anything that is not a Dashboard page with its sanitized 404 — never the page.
|
||||
for (const unknown of ["/definitely-not-a-real-module", "/settings-old", "/modules/example"]) {
|
||||
const response = await fetch(`${dashboardOrigin}${unknown}`);
|
||||
check(
|
||||
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
|
||||
`GET ${unknown} through the proxy is the API's sanitized 404, not a Dashboard page`,
|
||||
);
|
||||
}
|
||||
|
||||
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
|
||||
await script.arrayBuffer();
|
||||
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
|
||||
@@ -148,6 +173,10 @@ async function splitRoutingChecks() {
|
||||
"the Dashboard hostname refuses /display/*",
|
||||
);
|
||||
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /");
|
||||
check(
|
||||
await refusedByProxy(`${apiOrigin}/settings/displays`),
|
||||
"the API hostname serves no Dashboard page either",
|
||||
);
|
||||
check(
|
||||
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
|
||||
"the API hostname refuses the Dashboard's script",
|
||||
@@ -462,36 +491,69 @@ async function ownerCookie() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed-module checks (ADR 0048): whether `example` is in the module inventory, and what
|
||||
* its own route returns. Absent, it is in no inventory and its routes are not mounted at all;
|
||||
* present, its records route returns exactly the expected values, which proves its tables, its
|
||||
* dependencies, and the platform's database facade all work from module storage.
|
||||
* The installed-module checks (ADR 0048): whether the selected module is in the UI inventory and,
|
||||
* when one is supplied, whether its module-owned probe route responds. Core's own `example` fixture
|
||||
* retains its stronger records assertion, which also proves its database facade.
|
||||
*/
|
||||
async function moduleChecks() {
|
||||
const expectation = required("SMOKE_MODULE_EXPECT");
|
||||
const moduleId = optional("SMOKE_MODULE_ID") || "example";
|
||||
const probePath = optional("SMOKE_MODULE_PROBE_PATH");
|
||||
const cookie = await ownerCookie();
|
||||
const headers = { cookie, origin: dashboardOrigin };
|
||||
|
||||
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
|
||||
const inventoryBody = await inventory.json();
|
||||
const installed =
|
||||
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === "example");
|
||||
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
|
||||
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === moduleId);
|
||||
|
||||
if (expectation === "absent") {
|
||||
check(inventory.status === 200 && !installed, "the module inventory has no `example` module");
|
||||
check(
|
||||
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
|
||||
"the absent module's routes are not mounted",
|
||||
inventory.status === 200 && !installed,
|
||||
`the module inventory has no \`${moduleId}\` module`,
|
||||
);
|
||||
} else {
|
||||
const expected = JSON.parse(expectation);
|
||||
check(installed, "the module inventory lists the `example` module");
|
||||
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
|
||||
check(installed, `the module inventory lists the \`${moduleId}\` module`);
|
||||
}
|
||||
|
||||
// The module's own Dashboard address (ADR 0057) exists exactly while the module is installed with a
|
||||
// Dashboard UI — disabled or not: the Dashboard page when it is, the API's sanitized 404 when it is
|
||||
// not. A disabled module is not running, but its address still opens the Dashboard.
|
||||
const pageExpectation =
|
||||
optional("SMOKE_MODULE_PAGE") || (expectation === "absent" ? "absent" : "served");
|
||||
const modulePage = await fetch(`${dashboardOrigin}/${moduleId}`);
|
||||
const modulePageBody = await modulePage.text();
|
||||
if (pageExpectation === "absent") {
|
||||
check(
|
||||
JSON.stringify(values) === JSON.stringify(expected),
|
||||
`the module's records route returns ${JSON.stringify(expected)}`,
|
||||
modulePage.status === 404 && modulePageBody === '{"code":"NOT_FOUND"}',
|
||||
`GET /${moduleId} is the sanitized 404 while the module is not installed`,
|
||||
);
|
||||
} else {
|
||||
check(
|
||||
modulePage.status === 200 && modulePageBody.includes('id="ekklesia-host"'),
|
||||
`GET /${moduleId} through the proxy is the Dashboard page while the module is installed`,
|
||||
);
|
||||
}
|
||||
|
||||
if (moduleId === "example") {
|
||||
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
|
||||
if (expectation === "absent") {
|
||||
check(
|
||||
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
|
||||
"the absent module's routes are not mounted",
|
||||
);
|
||||
} else {
|
||||
const expected = JSON.parse(expectation);
|
||||
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
|
||||
check(
|
||||
JSON.stringify(values) === JSON.stringify(expected),
|
||||
`the module's records route returns ${JSON.stringify(expected)}`,
|
||||
);
|
||||
}
|
||||
} else if (expectation !== "absent" && probePath) {
|
||||
const probe = await fetch(`${apiOrigin}${probePath}`, { headers });
|
||||
await probe.arrayBuffer();
|
||||
check(probe.status === 200, `the module probe ${probePath} responds`);
|
||||
}
|
||||
|
||||
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// stream-failure the first realtime connection is refused: is realtime re-established?
|
||||
// caching-probe can a display on the API origin store storage-origin images in Cache Storage?
|
||||
// stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline
|
||||
// revoke revoke the paired kiosk while its display is open
|
||||
// revoke revoke the paired kiosk while its display is open, then pair it again
|
||||
//
|
||||
// Every DevTools command and every page-side request has its own deadline, and a step has an overall
|
||||
// budget. A call that never answers fails the step with a line naming that exact call, because a call
|
||||
@@ -426,23 +426,48 @@ async function seedSignage(owner) {
|
||||
const base = "/api/v1/modules/signage";
|
||||
const images = [];
|
||||
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
|
||||
const uploaded = await owner.evaluate(
|
||||
`(async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 360;
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = ${JSON.stringify(colour)};
|
||||
context.fillRect(0, 0, 640, 360);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
const response = await fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
|
||||
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
|
||||
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
||||
});
|
||||
return { status: response.status, body: await response.json().catch(() => null) };
|
||||
})()`,
|
||||
`uploading slide ${index}`,
|
||||
);
|
||||
let uploaded;
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
uploaded = await owner.evaluate(
|
||||
`(async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 360;
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = ${JSON.stringify(colour)};
|
||||
context.fillRect(0, 0, 640, 360);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
return fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
|
||||
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
|
||||
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
||||
}).then(
|
||||
async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
|
||||
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
|
||||
);
|
||||
})()`,
|
||||
`uploading slide ${index}`,
|
||||
);
|
||||
if (
|
||||
attempt === 1 &&
|
||||
uploaded.status === 503 &&
|
||||
uploaded.body?.code === "SIGNAGE_STORAGE_UNAVAILABLE"
|
||||
) {
|
||||
note(`slide ${index} met one transient storage outage; retrying once`);
|
||||
await sleep(1_000);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (uploaded.status === "timeout") {
|
||||
throw new HangError(`HTTP POST ${base}/images from page "owner"`, requestTimeoutMs);
|
||||
}
|
||||
if (uploaded.status !== 200 && uploaded.status !== 201) {
|
||||
const code =
|
||||
typeof uploaded.body?.code === "string" && /^SIGNAGE_[A-Z_]+$/u.test(uploaded.body.code)
|
||||
? ` (${uploaded.body.code})`
|
||||
: "";
|
||||
throw new Error(`Uploading slide ${index} returned HTTP ${uploaded.status}${code}.`);
|
||||
}
|
||||
images.push(uploaded.body?.image?.id);
|
||||
}
|
||||
check(
|
||||
@@ -951,6 +976,32 @@ async function run() {
|
||||
result.stopped,
|
||||
`a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`,
|
||||
);
|
||||
// Revocation is recoverable: the same screen pairs again and resumes, keeping its place in the
|
||||
// module's roster. It also leaves the kiosk paired for the steps that run after this one.
|
||||
const rosterBefore = moduleId === "signage" ? (await signageViewers(owner.page)).length : 0;
|
||||
if (await page.waitFor(find("display-pairing-code"), "a fresh pairing code after revocation")) {
|
||||
const code = (
|
||||
await page.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code")
|
||||
).replace(/\D/gu, "");
|
||||
const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", {
|
||||
code,
|
||||
deviceName: "Repro kiosk",
|
||||
});
|
||||
check(approved.status === 200, "the owner approves the revoked screen's new code");
|
||||
check(
|
||||
await waitLive(page, "the re-paired display to go live"),
|
||||
"a revoked screen is paired again and resumes without a reload",
|
||||
);
|
||||
if (moduleId === "signage") {
|
||||
const rosterAfter = (await signageViewers(owner.page)).length;
|
||||
check(
|
||||
rosterAfter === rosterBefore,
|
||||
`re-pairing keeps the screen's existing roster entry (${rosterBefore} before, ${rosterAfter} after)`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
check(false, "a revoked screen shows a fresh pairing code");
|
||||
}
|
||||
await page.close();
|
||||
kiosk.close();
|
||||
await finishOwner(owner);
|
||||
|
||||
+200
-52
@@ -7,6 +7,8 @@
|
||||
# bash scripts/smoke-deploy.sh builds the image first
|
||||
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
||||
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
|
||||
# EKKLESIA_SMOKE_MODULE_RELEASE_DIR=<dir> EKKLESIA_SMOKE_MODULE_ID=<id> \
|
||||
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
|
||||
#
|
||||
# Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile.
|
||||
# It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the
|
||||
@@ -15,10 +17,10 @@
|
||||
# ADR 0048 end to end with that same image:
|
||||
#
|
||||
# - it boots with no modules;
|
||||
# - the repository's module template is packaged by the canonical packager as an Ekklesia Module
|
||||
# Package v1 (ADR 0049; scripts/lib/build-module-package.Dockerfile only provides the build
|
||||
# environment), verified by the same image's own verifier, extracted to staging, installed into
|
||||
# module storage, its migrations planned and explicitly applied, and the API restarted;
|
||||
# - either the repository's module template is packaged by the canonical packager, or an external
|
||||
# canonical release pair is supplied through EKKLESIA_SMOKE_MODULE_RELEASE_DIR; the package is
|
||||
# verified by the same image's own verifier, extracted to staging, installed into module storage,
|
||||
# its migrations planned and explicitly applied, and the API restarted;
|
||||
# - a real headless Chromium drives the Dashboard, the module, its display, and sign-out;
|
||||
# - the module is removed and the API restarted: Core still runs, migration planning reports the
|
||||
# module's retained history, and its data survives;
|
||||
@@ -83,6 +85,28 @@ same-origin | split) ;;
|
||||
;;
|
||||
esac
|
||||
|
||||
module_release_directory="${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"
|
||||
module_id="${EKKLESIA_SMOKE_MODULE_ID:-example}"
|
||||
module_probe_path="${EKKLESIA_SMOKE_MODULE_PROBE_PATH:-}"
|
||||
[[ "$module_id" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]] || {
|
||||
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_ID is not a valid module id." >&2
|
||||
exit 1
|
||||
}
|
||||
if [[ -n "$module_release_directory" ]]; then
|
||||
[[ "$topology" == split ]] || {
|
||||
echo "smoke-deploy: an external module release requires the split topology." >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -d "$module_release_directory" ]] || {
|
||||
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_RELEASE_DIR is not a directory." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
if [[ -n "$module_probe_path" && "$module_probe_path" != "/api/v1/modules/${module_id}/"* ]]; then
|
||||
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_PROBE_PATH must be inside the selected module's API namespace." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_id="$(openssl rand -hex 4)"
|
||||
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
|
||||
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries
|
||||
@@ -217,22 +241,31 @@ if [[ -z "$image" ]]; then
|
||||
fi
|
||||
|
||||
if [[ "$topology" == split ]]; then
|
||||
# The module template as a Module Package v1, from the canonical packager. Never a second platform
|
||||
# image: the package is installed into module storage below, after the image verifies it.
|
||||
step "Packaging the module template with the canonical Ekklesia packager"
|
||||
source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
|
||||
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
|
||||
DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/build-module-package.Dockerfile \
|
||||
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
|
||||
--output "type=local,dest=${work}/module-package" . >/dev/null ||
|
||||
fail "the module package did not build."
|
||||
# Never a second platform image: the package is installed into module storage below, after the
|
||||
# generic image verifies it. The default keeps Core's own template smoke self-contained; downstream
|
||||
# module CI hands this harness the exact release pair it built instead.
|
||||
if [[ -z "$module_release_directory" ]]; then
|
||||
step "Packaging the module template with the canonical Ekklesia packager"
|
||||
source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
|
||||
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
|
||||
DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/build-module-package.Dockerfile \
|
||||
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
|
||||
--output "type=local,dest=${work}/module-package" . >/dev/null ||
|
||||
fail "the module package did not build."
|
||||
package_output="$work/module-package"
|
||||
else
|
||||
step "Accepting the external module's canonical release pair"
|
||||
package_output="$module_release_directory"
|
||||
fi
|
||||
# The archive and its digest are one release: copied together, from this run's own build output,
|
||||
# into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is
|
||||
# mode 0700 and owned by whoever built it, so it is never handed to the verifier itself.
|
||||
package_archive="$(module_package_release "$work/module-package" "$work/package")" ||
|
||||
package_archive="$(module_package_release "$package_output" "$work/package")" ||
|
||||
fail "the packager did not produce exactly one archive and its digest."
|
||||
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
|
||||
fail "the packager produced an unexpected archive name."
|
||||
if [[ -z "$module_release_directory" ]]; then
|
||||
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
|
||||
fail "the packager produced an unexpected archive name."
|
||||
fi
|
||||
echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))"
|
||||
fi
|
||||
|
||||
@@ -446,22 +479,48 @@ module_storage() {
|
||||
install_test_module() {
|
||||
local verified
|
||||
verified="$(
|
||||
docker run --rm --network none --read-only --tmpfs /tmp --cap-drop ALL \
|
||||
--security-opt no-new-privileges \
|
||||
docker run --rm --network none --read-only \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||
-v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \
|
||||
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
|
||||
--sha256-file "/package/${package_archive}.sha256" --extract-to /staging/example 2>&1
|
||||
--sha256-file "/package/${package_archive}.sha256" --extract-to "/staging/${module_id}" 2>&1
|
||||
)" || fail "the module package did not verify: ${verified}"
|
||||
echo "ok ${verified%%$'\n'*}"
|
||||
docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
|
||||
-v "$daemon_work/staging:/staging" "$postgres_image" -ec '
|
||||
rm -rf /modules/.incoming-example && cp -R /staging/example /modules/.incoming-example &&
|
||||
rm -rf /staging/example && chown -R 0:0 /modules/.incoming-example &&
|
||||
chmod -R a+rX,go-w /modules/.incoming-example &&
|
||||
mv /modules/.incoming-example /modules/example' ||
|
||||
-v "$daemon_work/staging:/staging" -e MODULE_ID="$module_id" "$postgres_image" -ec '
|
||||
incoming="/modules/.incoming-${MODULE_ID}"
|
||||
installed="/modules/${MODULE_ID}"
|
||||
staged="/staging/${MODULE_ID}"
|
||||
rm -rf "$incoming" && cp -R "$staged" "$incoming" && rm -rf "$staged" &&
|
||||
chown -R 0:0 "$incoming" && chmod -R a+rX,go-w "$incoming" && mv "$incoming" "$installed"' ||
|
||||
fail "the verified module could not be installed into module storage."
|
||||
}
|
||||
|
||||
# The package and its claimed digest are deliberately made to disagree. The verifier must reject it
|
||||
# before extraction; the valid release pair is left untouched for the installation that follows.
|
||||
reject_invalid_package() {
|
||||
local rejected zero_digest
|
||||
zero_digest="$(printf '0%.0s' {1..64})"
|
||||
if rejected="$(
|
||||
docker run --rm --network none --read-only \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||
-v "$daemon_work/package:/package:ro" \
|
||||
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
|
||||
--sha256 "$zero_digest" 2>&1
|
||||
)"; then
|
||||
fail "the verifier accepted a package whose archive did not match its claimed digest."
|
||||
fi
|
||||
grep -q '^\[MODULE_PACKAGE_DIGEST_MISMATCH\]' <<<"$rejected" ||
|
||||
fail "the invalid package was rejected for an unexpected reason."
|
||||
echo "ok an invalid package is rejected before installation"
|
||||
}
|
||||
|
||||
# restart_api <command...>: stops the API, runs the command, and starts the API again. A controlled
|
||||
# restart is the only way a module change is picked up; nothing is ever hot loaded.
|
||||
restart_api() {
|
||||
@@ -473,9 +532,25 @@ restart_api() {
|
||||
fi
|
||||
}
|
||||
|
||||
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
|
||||
# recreate_api removes the application container, not its installation-owned module bind mount or
|
||||
# database volumes, then creates a fresh container from the same generic image.
|
||||
recreate_api() {
|
||||
"${compose[@]}" stop api >/dev/null 2>&1 || fail "the API did not stop for recreation."
|
||||
"${compose[@]}" rm --force api >/dev/null 2>&1 || fail "the API container was not removed."
|
||||
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 api 2>&1)"; then
|
||||
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||
fail "the recreated API did not become healthy."
|
||||
fi
|
||||
}
|
||||
|
||||
# module_check <expectation> [page]: "absent", or the exact JSON array of values `example` must
|
||||
# return. `page` is whether its Dashboard address is served (ADR 0057): by default, exactly when the
|
||||
# module is running; "served" for a module that is installed but disabled, whose address still opens
|
||||
# the Dashboard so it can say the module is unavailable.
|
||||
module_check() {
|
||||
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
|
||||
-e SMOKE_MODULE_PAGE="${2:-}" \
|
||||
-e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \
|
||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
|
||||
}
|
||||
|
||||
@@ -488,21 +563,36 @@ if [[ "$topology" == split ]]; then
|
||||
step "Booting the generic image with empty module storage"
|
||||
module_check absent || fail "the image without modules did not run module-free."
|
||||
|
||||
step "Rejecting an invalid package before installation"
|
||||
reject_invalid_package
|
||||
|
||||
step "Installing the test module into module storage, migrating explicitly, and restarting the API"
|
||||
install_module_and_migrate() {
|
||||
install_test_module
|
||||
local plan
|
||||
plan="$(plan_output)"
|
||||
grep -q '^- \[pending\] example:0000_initial ' <<<"$plan" ||
|
||||
grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan" ||
|
||||
fail "the plan does not list the installed module's migration as pending."
|
||||
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||
fail "applying the installed module's migration failed."
|
||||
plan="$(plan_output)"
|
||||
grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
|
||||
fail "the plan does not list the installed module's migration as applied."
|
||||
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
|
||||
fail "the installed module still has a pending migration after apply."
|
||||
fi
|
||||
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||
fail "reapplying migrations was not idempotent."
|
||||
}
|
||||
restart_api install_module_and_migrate
|
||||
module_check '[]' || fail "the installed module did not run from module storage."
|
||||
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
|
||||
>/dev/null || fail "could not write the module's own data."
|
||||
module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
|
||||
if [[ "$module_id" == example ]]; then
|
||||
module_check '[]' || fail "the installed module did not run from module storage."
|
||||
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
|
||||
>/dev/null || fail "could not write the module's own data."
|
||||
module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
|
||||
else
|
||||
module_check present || fail "the installed module did not run from module storage."
|
||||
fi
|
||||
|
||||
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
|
||||
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
|
||||
@@ -513,24 +603,31 @@ if [[ "$topology" == split ]]; then
|
||||
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
|
||||
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
|
||||
browser_container="${project}-browser"
|
||||
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
|
||||
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
|
||||
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
|
||||
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
|
||||
"$browser_image" \
|
||||
"--ignore-certificate-errors-spki-list=${spki}" \
|
||||
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
|
||||
start_owner_browser() {
|
||||
docker rm --force "$browser_container" >/dev/null 2>&1 || true
|
||||
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
|
||||
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
|
||||
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
|
||||
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
|
||||
"$browser_image" \
|
||||
"--ignore-certificate-errors-spki-list=${spki}" \
|
||||
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
|
||||
}
|
||||
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
|
||||
stage browser.mjs
|
||||
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||
--network "$edge_network" \
|
||||
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
||||
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
|
||||
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
|
||||
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed."
|
||||
run_browser_checks() {
|
||||
start_owner_browser
|
||||
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||
--network "$edge_network" \
|
||||
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
||||
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
|
||||
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
|
||||
--entrypoint node "$image" /smoke/browser.mjs
|
||||
}
|
||||
run_browser_checks || fail "browser checks failed."
|
||||
|
||||
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
||||
# A kiosk: a second browser whose profile directory is a volume, so it outlives the container
|
||||
@@ -566,6 +663,7 @@ if [[ "$topology" == split ]]; then
|
||||
--network "$edge_network" \
|
||||
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
||||
-e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \
|
||||
-e SMOKE_CALL_TIMEOUT_MS="${EKKLESIA_SMOKE_CALL_TIMEOUT_MS:-30000}" \
|
||||
-e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \
|
||||
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
|
||||
-e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \
|
||||
@@ -607,29 +705,79 @@ if [[ "$topology" == split ]]; then
|
||||
((display_status == 0)) || fail "display-device checks failed."
|
||||
fi
|
||||
|
||||
step "Restarting the API process with the installed module and data intact"
|
||||
restart_api
|
||||
if [[ "$module_id" == example ]]; then
|
||||
module_check '["Hello, kept"]' || fail "the module or its data did not survive an API restart."
|
||||
else
|
||||
module_check present || fail "the module did not survive an API restart."
|
||||
fi
|
||||
run_browser_checks || fail "the module UI did not survive an API restart."
|
||||
|
||||
step "Recreating the API container with the same module storage and database volumes"
|
||||
recreate_api
|
||||
if [[ "$module_id" == example ]]; then
|
||||
module_check '["Hello, kept"]' || fail "the module or its data did not survive container recreation."
|
||||
else
|
||||
module_check present || fail "the module did not survive container recreation."
|
||||
fi
|
||||
plan="$(plan_output)"
|
||||
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
|
||||
fail "container recreation made an applied module migration pending again."
|
||||
fi
|
||||
run_browser_checks || fail "the module UI did not survive container recreation."
|
||||
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
||||
display_driver 120 restart || fail "the display runtime or its data did not survive container recreation."
|
||||
fi
|
||||
|
||||
step "Disabling and re-enabling the module through the supported lifecycle command"
|
||||
restart_api "${compose[@]}" run --rm --no-deps migrate node \
|
||||
core/dist/cli/set-module-enabled.js --disable --module "$module_id"
|
||||
module_check absent served || fail "the disabled module was still reported as running."
|
||||
module_storage "test -d /modules/${module_id}" ||
|
||||
fail "disabling the module removed its installed package."
|
||||
restart_api "${compose[@]}" run --rm --no-deps migrate node \
|
||||
core/dist/cli/set-module-enabled.js --enable --module "$module_id"
|
||||
if [[ "$module_id" == example ]]; then
|
||||
module_check '["Hello, kept"]' || fail "the re-enabled module did not return with its data."
|
||||
else
|
||||
module_check present || fail "the re-enabled module did not return."
|
||||
fi
|
||||
run_browser_checks || fail "the re-enabled module UI did not return."
|
||||
|
||||
step "Removing the module from module storage and restarting: Core runs, its data stays"
|
||||
restart_api module_storage 'rm -rf /modules/example'
|
||||
restart_api module_storage "rm -rf /modules/${module_id}"
|
||||
module_check absent || fail "the removed module is still running."
|
||||
plan="$(plan_output)"
|
||||
grep -q '^- \[retained\] example: 1 applied migration kept; the module is not installed$' \
|
||||
grep -q "^- \\[retained\\] ${module_id}: .*applied migration.*kept; the module is not installed$" \
|
||||
<<<"$plan" || fail "migration planning did not report the removed module's retained history."
|
||||
grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" ||
|
||||
fail "migration planning did not succeed cleanly with the module absent."
|
||||
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||
fail "applying migrations failed with the module absent."
|
||||
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
|
||||
fail "the removed module's data did not survive."
|
||||
if [[ "$module_id" == example ]]; then
|
||||
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
|
||||
fail "the removed module's data did not survive."
|
||||
fi
|
||||
echo "ok the module is gone, Core runs, its history is retained, and its data survives"
|
||||
|
||||
step "Reinstalling the exact same package and restarting: the module returns with its previous data"
|
||||
restart_api install_test_module
|
||||
plan="$(plan_output)"
|
||||
grep -q '^- \[applied\] example:0000_initial ' <<<"$plan" ||
|
||||
grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
|
||||
fail "the restored module's migration history is not valid."
|
||||
if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then
|
||||
fail "the restored module left retained or pending migrations."
|
||||
fi
|
||||
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
|
||||
if [[ "$module_id" == example ]]; then
|
||||
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
|
||||
else
|
||||
module_check present || fail "the restored module did not return."
|
||||
fi
|
||||
run_browser_checks || fail "the restored module UI did not return."
|
||||
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
||||
display_driver 120 restart || fail "the restored display runtime did not return with its data."
|
||||
fi
|
||||
echo "ok the restored module runs again, with its history strict and its data intact"
|
||||
fi
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
#
|
||||
# bash scripts/test-ci-sweep.sh
|
||||
#
|
||||
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, so
|
||||
# it can never touch a real stack; and it removes all of them on exit, whatever happens.
|
||||
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, and
|
||||
# every sweep it runs is confined to those identifiers, so it can never touch a real stack: the
|
||||
# runner's daemon is shared, and another job's live stack may be on it right now. It removes all of
|
||||
# its own resources on exit, whatever happens.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
@@ -91,10 +93,14 @@ source "$repository_root/scripts/lib/ci-project.sh"
|
||||
# Fake run identifiers: nothing here can match a real run's stacks.
|
||||
export GITHUB_RUN_ID="sweeptest${nonce}"
|
||||
export GITHUB_RUN_ATTEMPT="1"
|
||||
# Every stack this test sweeps begins with this; nothing it sweeps reaches past it.
|
||||
own_run="$(ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID}-")"
|
||||
mine="$(EKKLESIA_CI_JOB_KEY=alpha ci_project_name mine)"
|
||||
sibling="$(EKKLESIA_CI_JOB_KEY=beta ci_project_name sibling)"
|
||||
foreign="ekklesia-sweep-control-${nonce}"
|
||||
for project in "$mine" "$sibling" "$foreign"; do
|
||||
# Stands in for another run's live stack on the shared daemon — a module's integration job, say.
|
||||
bystander="$(GITHUB_RUN_ID="bystander${nonce}" EKKLESIA_CI_JOB_KEY=gamma ci_project_name live)"
|
||||
for project in "$mine" "$sibling" "$foreign" "$bystander"; do
|
||||
make_stack "$project"
|
||||
done
|
||||
# A job container connected to this job's stack network, exactly as compose-job-network.sh does.
|
||||
@@ -115,12 +121,19 @@ check '! env -u GITHUB_RUN_ID bash scripts/ci-sweep.sh --job >/dev/null 2>&1' \
|
||||
"--job without GITHUB_RUN_ID fails"
|
||||
|
||||
echo "--- --stale removes only CI stacks older than its threshold"
|
||||
bash scripts/ci-sweep.sh --stale
|
||||
bash scripts/ci-sweep.sh --stale --prefix "$own_run"
|
||||
check 'project_exists "$sibling"' "a young CI stack survives the default threshold"
|
||||
bash scripts/ci-sweep.sh --stale --max-age-seconds 0
|
||||
bash scripts/ci-sweep.sh --stale --max-age-seconds 0 --prefix "$own_run"
|
||||
check '! project_exists "$sibling"' "a CI stack older than the threshold is removed"
|
||||
check '! staging_exists "$sibling"' "its staging directory too"
|
||||
check 'project_exists "$foreign"' "a stack outside the CI prefix is never removed, at any age"
|
||||
check 'project_exists "$bystander"' \
|
||||
"another run's stack is never removed by a narrowed sweep, even past the threshold"
|
||||
echo "--- --prefix only ever narrows the sweep"
|
||||
check '! bash scripts/ci-sweep.sh --stale --prefix "ekklesia-" >/dev/null 2>&1' \
|
||||
"a prefix outside the CI prefix is refused"
|
||||
check '! bash scripts/ci-sweep.sh --job --prefix "$own_run" >/dev/null 2>&1' \
|
||||
"--prefix with --job is refused"
|
||||
|
||||
if ((failures > 0)); then
|
||||
echo "test-ci-sweep: ${failures} check(s) failed." >&2
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# ADR 0055's full proof, with real sources: the native proof module (sharp 0.35.5, libvips 1.3.4) and
|
||||
# its corresponding-source artifact.
|
||||
#
|
||||
# bash scripts/test-corresponding-source.sh
|
||||
#
|
||||
# 1. Acquisition (network, first run only): every checked-in recipe's recorded source files are
|
||||
# downloaded into a verified cache, each checked against its recorded size and SHA-256. A file
|
||||
# already in the cache with the right bytes is never fetched again.
|
||||
# 2. Construction and the gate (offline): the artifact is built twice from unrelated directories and
|
||||
# must be byte-identical; the public-distribution gate must approve it, and must refuse it once it
|
||||
# is removed or corrupted; sharp must still run from the verified binary package.
|
||||
#
|
||||
# EKKLESIA_CORRESPONDING_SOURCE_CACHE chooses the cache directory; the default is
|
||||
# ${XDG_CACHE_HOME:-$HOME/.cache}/ekklesia/corresponding-sources. It is about 200 MB per recipe.
|
||||
set -Eeuo pipefail
|
||||
|
||||
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$root"
|
||||
cache="${EKKLESIA_CORRESPONDING_SOURCE_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/ekklesia/corresponding-sources}"
|
||||
|
||||
pnpm exec turbo run build --filter=@ekklesia/module-pack... --filter=@ekklesia/core... >/dev/null
|
||||
|
||||
recipes="packages/module-pack/corresponding-sources"
|
||||
while IFS= read -r recipe; do
|
||||
relative="${recipe#"$recipes"/}"
|
||||
relative="${relative%/recipe.json}"
|
||||
echo "==> Acquiring the recorded sources for ${relative%/*}@${relative##*/}"
|
||||
node packages/module-pack/bin/ekklesia-corresponding-source.mjs acquire \
|
||||
--recipe "${relative%/*}@${relative##*/}" --cache "$cache"
|
||||
done < <(find "$recipes" -name recipe.json | LC_ALL=C sort)
|
||||
|
||||
echo "==> Building, gating, and running the native proof offline"
|
||||
EKKLESIA_CORRESPONDING_SOURCE_CACHE="$cache" EKKLESIA_CORRESPONDING_SOURCE_REQUIRED=1 \
|
||||
pnpm --filter @ekklesia/module-pack exec vitest run tests/corresponding-source-native.test.ts
|
||||
@@ -110,7 +110,7 @@ for user in publisher outsider; do
|
||||
password_variable="${user}_password"
|
||||
api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null
|
||||
done
|
||||
owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
|
||||
owners=(Zayin ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
|
||||
for owner in "${owners[@]}"; do
|
||||
api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null
|
||||
team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/shell",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -16,6 +16,10 @@
|
||||
"types": "./dist/dashboard-assets.d.ts",
|
||||
"import": "./dist/dashboard-assets.js"
|
||||
},
|
||||
"./dashboard-routes": {
|
||||
"types": "./dist/dashboard-routes.d.ts",
|
||||
"import": "./dist/dashboard-routes.js"
|
||||
},
|
||||
"./display-assets": {
|
||||
"types": "./dist/display-assets.d.ts",
|
||||
"import": "./dist/display-assets.js"
|
||||
@@ -33,6 +37,7 @@
|
||||
"@ekklesia/module-ui-host": "workspace:*",
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"@ekklesia/ui": "workspace:*",
|
||||
"lucide-react": "1.48.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
|
||||
@@ -89,7 +89,13 @@ await copyFile(
|
||||
if (serve) {
|
||||
const context = await esbuild.context(buildOptions);
|
||||
await context.watch();
|
||||
const { host, port } = await context.serve({ servedir: outputDirectory });
|
||||
// Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as
|
||||
// `/example` reaches the shell's router here as it does from `apps/api` (ADR 0057).
|
||||
// Development only: the API serves the page only at its platform pages and installed modules'.
|
||||
const { host, port } = await context.serve({
|
||||
fallback: path.join(outputDirectory, "index.html"),
|
||||
servedir: outputDirectory,
|
||||
});
|
||||
console.log(`Ekklesia shell (development) on http://${host}:${port}`);
|
||||
console.log(
|
||||
apiBaseUrl === ""
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useNavTooltip } from "./NavTooltip.js";
|
||||
import { controlHeight, type NavigationPresentation } from "./ShellNavigation.js";
|
||||
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
|
||||
|
||||
export interface AccountMenuProps {
|
||||
readonly presentation: NavigationPresentation;
|
||||
readonly onSignOut: () => void;
|
||||
}
|
||||
|
||||
interface AccountAction {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly icon: ShellIconName;
|
||||
readonly testId: string;
|
||||
readonly run: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in person's account control, at the bottom of the navigation (DESIGN.md §4.5).
|
||||
*
|
||||
* A product-owned menu button — never browser UI — following the WAI-ARIA menu button pattern: the
|
||||
* trigger says it opens a menu and whether it is open; opening moves focus to the first item (or
|
||||
* the last, from ArrowUp); arrow keys, Home, and End move between items; Escape closes it and
|
||||
* returns focus to the trigger; Tab or a pointer press outside closes it and leaves focus where it
|
||||
* went.
|
||||
*
|
||||
* It offers **only actions that work today** — Sign out. Profile, personal preferences, and
|
||||
* appearance belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages; until then there
|
||||
* is no dead link to `/account`. The shell knows the person only by an opaque user id
|
||||
* (`/api/v1/principal`), which is never shown, so the control names the area rather than the person.
|
||||
*/
|
||||
export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const menuId = useId();
|
||||
const triggerId = useId();
|
||||
const { collapsed } = props.presentation;
|
||||
const { tooltip, triggerProps } = useNavTooltip("Your account", collapsed && !open);
|
||||
|
||||
const close = useCallback((returnFocus: boolean) => {
|
||||
setOpen(false);
|
||||
if (returnFocus) {
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const actions: readonly AccountAction[] = [
|
||||
{
|
||||
icon: "signOut",
|
||||
id: "sign-out",
|
||||
label: "Sign out",
|
||||
run: () => {
|
||||
setOpen(false);
|
||||
props.onSignOut();
|
||||
},
|
||||
testId: "host-logout",
|
||||
},
|
||||
];
|
||||
|
||||
const openMenu = (focus: "first" | "last"): void => {
|
||||
setInitialFocus(focus);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
|
||||
(initialFocus === "last" ? items.at(-1) : items[0])?.focus();
|
||||
}, [initialFocus, open]);
|
||||
|
||||
// A press anywhere outside the menu and its trigger dismisses it, leaving focus alone.
|
||||
useEffect(() => {
|
||||
const owner = wrapperRef.current?.ownerDocument;
|
||||
if (!open || !owner) {
|
||||
return;
|
||||
}
|
||||
const dismiss = (event: Event): void => {
|
||||
if (!(event.target instanceof Node) || !wrapperRef.current?.contains(event.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
owner.addEventListener("pointerdown", dismiss);
|
||||
return () => owner.removeEventListener("pointerdown", dismiss);
|
||||
}, [open]);
|
||||
|
||||
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
|
||||
const index = items.indexOf(event.target as HTMLButtonElement);
|
||||
const focusAt = (next: number): void => {
|
||||
event.preventDefault();
|
||||
items[(next + items.length) % items.length]?.focus();
|
||||
};
|
||||
if (event.key === "Escape") {
|
||||
// Only the menu closes: a drawer around it stays open.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close(true);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
focusAt(index + 1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
focusAt(index - 1);
|
||||
} else if (event.key === "Home") {
|
||||
focusAt(0);
|
||||
} else if (event.key === "End") {
|
||||
focusAt(items.length - 1);
|
||||
} else if (event.key === "Tab") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
{open ? (
|
||||
<div
|
||||
aria-labelledby={triggerId}
|
||||
className="ek-shell-menu"
|
||||
data-testid="host-account-menu-list"
|
||||
id={menuId}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
role="menu"
|
||||
>
|
||||
{actions.map((action, index) => (
|
||||
<button
|
||||
className="ek-button ek-focusable ek-button--quiet ek-shell-menu__item"
|
||||
data-testid={action.testId}
|
||||
key={action.id}
|
||||
onClick={action.run}
|
||||
ref={(element) => {
|
||||
itemRefs.current[index] = element;
|
||||
}}
|
||||
role="menuitem"
|
||||
style={{ minHeight: controlHeight(props.presentation) }}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
<ShellIcon name={action.icon} />
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : undefined}
|
||||
<button
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className="ek-button ek-focusable ek-button--quiet ek-shell-nav-item"
|
||||
data-testid="host-account-menu"
|
||||
id={triggerId}
|
||||
onClick={() => (open ? close(false) : openMenu("first"))}
|
||||
ref={triggerRef}
|
||||
style={{ minHeight: controlHeight(props.presentation), width: "100%" }}
|
||||
type="button"
|
||||
{...triggerProps}
|
||||
onKeyDown={(event) => {
|
||||
triggerProps.onKeyDown(event);
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
openMenu("first");
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
openMenu("last");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="account" />
|
||||
<span className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"}>
|
||||
Your account
|
||||
</span>
|
||||
</button>
|
||||
{tooltip}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type {
|
||||
CSSProperties,
|
||||
FocusEventHandler,
|
||||
KeyboardEventHandler,
|
||||
MouseEvent,
|
||||
MouseEventHandler,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
|
||||
export interface DashboardLinkProps {
|
||||
readonly history: HostHistory;
|
||||
readonly to: DashboardDestination;
|
||||
readonly children: ReactNode;
|
||||
/** Marks the link as the page currently open, for assistive technology and visually. */
|
||||
readonly current?: boolean;
|
||||
readonly variant?: "navigation" | "action";
|
||||
/** Called after an in-place navigation, including to the page already open — for a drawer that
|
||||
* closes once a destination is chosen. Not called for a click the browser handles itself. */
|
||||
readonly onNavigate?: () => void;
|
||||
readonly className?: string;
|
||||
readonly style?: CSSProperties;
|
||||
readonly onBlur?: FocusEventHandler<HTMLAnchorElement>;
|
||||
readonly onFocus?: FocusEventHandler<HTMLAnchorElement>;
|
||||
readonly onKeyDown?: KeyboardEventHandler<HTMLAnchorElement>;
|
||||
readonly onMouseEnter?: MouseEventHandler<HTMLAnchorElement>;
|
||||
readonly onMouseLeave?: MouseEventHandler<HTMLAnchorElement>;
|
||||
readonly "data-testid"?: string;
|
||||
}
|
||||
|
||||
const variantClass = {
|
||||
action: "ek-button ek-focusable ek-button--secondary",
|
||||
navigation: "ek-button ek-focusable ek-button--quiet",
|
||||
} as const;
|
||||
|
||||
const navigationStyle: CSSProperties = {
|
||||
justifyContent: "flex-start",
|
||||
textDecoration: "none",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
/** The open page is shown by weight as well as colour, so it never depends on colour alone. */
|
||||
const currentStyle: CSSProperties = {
|
||||
...navigationStyle,
|
||||
background: designToken("state.selected-background"),
|
||||
color: designToken("state.selected-foreground"),
|
||||
fontWeight: designToken("typography.weight-bold"),
|
||||
};
|
||||
|
||||
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
|
||||
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
|
||||
return (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A link to a Dashboard page, as a real `<a href>` (ADR 0057).
|
||||
*
|
||||
* A real link rather than a button, because it is one: it can be opened in a new tab, copied, and
|
||||
* bookmarked, and assistive technology announces it as a link. An ordinary click opens the page in
|
||||
* place through the shell's history instead of reloading the document; every other click — with a
|
||||
* modifier key, or the middle button — is left to the browser.
|
||||
*/
|
||||
export function DashboardLink(props: DashboardLinkProps): ReactElement {
|
||||
const href = dashboardPath(props.to);
|
||||
const variant = props.variant ?? "navigation";
|
||||
const baseStyle =
|
||||
variant === "action"
|
||||
? { textDecoration: "none" }
|
||||
: props.current
|
||||
? currentStyle
|
||||
: navigationStyle;
|
||||
return (
|
||||
<a
|
||||
aria-current={props.current ? "page" : undefined}
|
||||
className={
|
||||
props.className ? `${variantClass[variant]} ${props.className}` : variantClass[variant]
|
||||
}
|
||||
data-testid={props["data-testid"]}
|
||||
href={href}
|
||||
onBlur={props.onBlur}
|
||||
onClick={(event) => {
|
||||
if (isBrowserHandledClick(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
props.history.push(href);
|
||||
props.onNavigate?.();
|
||||
}}
|
||||
onFocus={props.onFocus}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onMouseEnter={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
style={props.style ? { ...baseStyle, ...props.style } : baseStyle}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import { Button, Field, TextInput } from "@ekklesia/ui";
|
||||
import { Button, Dialog, Field, TextInput } from "@ekklesia/ui";
|
||||
import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
@@ -61,6 +61,10 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [pairing, setPairing] = useState<PairingStep>({ step: "idle" });
|
||||
/** The device an administrator asked to revoke and has not yet confirmed. */
|
||||
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
@@ -106,13 +110,15 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
||||
};
|
||||
|
||||
const revoke = async (deviceId: string): Promise<void> => {
|
||||
setRevoking(true);
|
||||
try {
|
||||
await revokeHostDevice(config, deviceId);
|
||||
await refresh();
|
||||
} catch {
|
||||
// Left to the next refresh rather than asserting a failure the operator cannot act on.
|
||||
}
|
||||
await refresh();
|
||||
setRevoking(false);
|
||||
setConfirming(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -195,7 +201,7 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
||||
{device.revoked ? undefined : (
|
||||
<Button
|
||||
data-testid={`device-revoke-${device.id}`}
|
||||
onClick={() => void revoke(device.id)}
|
||||
onClick={() => setConfirming(device)}
|
||||
size="sm"
|
||||
>
|
||||
Revoke
|
||||
@@ -203,6 +209,38 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Revoking strands the screen until someone re-pairs it in person, so the consequence is
|
||||
spelled out before it happens — never a browser-native confirm. */}
|
||||
<Dialog
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
data-testid="device-revoke-cancel"
|
||||
disabled={revoking}
|
||||
onClick={dismissConfirmation}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="device-revoke-confirm"
|
||||
disabled={revoking}
|
||||
onClick={() => (confirming ? void revoke(confirming.id) : undefined)}
|
||||
variant="danger"
|
||||
>
|
||||
Revoke display
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onDismiss={dismissConfirmation}
|
||||
open={confirming !== undefined}
|
||||
title={`Revoke ${confirming?.name ?? "this display"}?`}
|
||||
>
|
||||
<p style={{ margin: 0 }}>
|
||||
The screen stops showing content within a few seconds and returns to its pairing code.
|
||||
This cannot be undone: to use it again, pair it from the screen with a new code.
|
||||
</p>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||
import { Button } from "@ekklesia/ui";
|
||||
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import {
|
||||
HostAuthError,
|
||||
type HostAuthLanding,
|
||||
@@ -12,6 +20,7 @@ import {
|
||||
signInWithEmail,
|
||||
signOutHost,
|
||||
} from "../auth-protocol.js";
|
||||
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
|
||||
import {
|
||||
bootstrapHost,
|
||||
type DashboardNavigationEntry,
|
||||
@@ -20,11 +29,15 @@ import {
|
||||
HostProtocolError,
|
||||
loadHostPrincipal,
|
||||
} from "../host-protocol.js";
|
||||
import { DevicePanel } from "./DevicePanel.js";
|
||||
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js";
|
||||
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
|
||||
import { LoginForm } from "./LoginForm.js";
|
||||
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
||||
import { ResetPasswordForm } from "./ResetPasswordForm.js";
|
||||
import { ShellLayout } from "./ShellLayout.js";
|
||||
import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js";
|
||||
import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
|
||||
|
||||
type AuthenticatedPrincipal = Extract<
|
||||
@@ -101,75 +114,35 @@ export interface HostAppProps {
|
||||
* so there is nothing an open redirect could use.
|
||||
*/
|
||||
readonly authLanding?: HostAuthLanding;
|
||||
/**
|
||||
* The address bar (ADR 0057). The current path decides which page is open, so a refresh, a
|
||||
* bookmark, and Back and Forward all land where the person was. The entry point passes the
|
||||
* browser's; tests pass an in-memory one. Must be a stable reference.
|
||||
*/
|
||||
readonly history: HostHistory;
|
||||
/** Names the browser tab after the open page. Supplied by the entry point, which owns `document`;
|
||||
* left unset, the shell names nothing. */
|
||||
readonly setDocumentTitle?: (title: string) => void;
|
||||
/**
|
||||
* Whether the page is laid out for a phone (ADR 0058). The entry point passes the browser's media
|
||||
* query; left unset, the wide layout. Must be a stable reference.
|
||||
*/
|
||||
readonly viewport?: HostViewport;
|
||||
/**
|
||||
* Where the desktop sidebar's expanded or collapsed mode is remembered (ADR 0058). The entry point
|
||||
* passes this browser's storage; left unset, the mode lasts only as long as this component.
|
||||
*/
|
||||
readonly sidebarPreference?: SidebarPreference;
|
||||
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's own chrome, expressed in the platform's global semantic tokens rather than in literal
|
||||
* colours (ADR 0039). Nothing here is a brand decision any more: changing
|
||||
* `--ekklesia-background-page` at the host root changes the Dashboard and every mounted module
|
||||
* artifact together, because custom properties inherit through the artifacts' isolated roots.
|
||||
* The host's full-page messages (loading, failure, sign-out), in the platform's semantic tokens
|
||||
* (ADR 0039). The signed-in Dashboard's own frame is `ShellLayout` (ADR 0058).
|
||||
*/
|
||||
const shellStyles = {
|
||||
actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" },
|
||||
aside: {
|
||||
background: designToken("background.canvas"),
|
||||
borderRight: `${designToken("border.width")} solid ${designToken("border.divider")}`,
|
||||
boxSizing: "border-box",
|
||||
minWidth: "15rem",
|
||||
overflowY: "auto",
|
||||
padding: designToken("spacing.xl"),
|
||||
width: "15rem",
|
||||
},
|
||||
brand: { alignItems: "center", display: "flex", gap: designToken("spacing.sm") },
|
||||
brandMark: {
|
||||
background: designToken("accent.base"),
|
||||
borderRadius: designToken("radius.md"),
|
||||
boxShadow: `inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")}`,
|
||||
display: "block",
|
||||
height: designToken("spacing.xl"),
|
||||
width: designToken("spacing.xl"),
|
||||
},
|
||||
groupLabel: {
|
||||
color: designToken("text.muted"),
|
||||
fontSize: designToken("typography.size-xs"),
|
||||
letterSpacing: "0.05em",
|
||||
margin: `0 0 ${designToken("spacing.xs")}`,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
header: {
|
||||
alignItems: "center",
|
||||
background: designToken("background.surface"),
|
||||
borderBottom: `${designToken("border.width")} solid ${designToken("border.divider")}`,
|
||||
display: "flex",
|
||||
fontWeight: designToken("typography.weight-bold"),
|
||||
gridColumn: "1 / -1",
|
||||
justifyContent: "space-between",
|
||||
padding: `${designToken("spacing.md")} ${designToken("spacing.xl")}`,
|
||||
},
|
||||
heading: {
|
||||
fontSize: designToken("typography.size-lg"),
|
||||
fontWeight: designToken("typography.weight-bold"),
|
||||
margin: 0,
|
||||
},
|
||||
layout: {
|
||||
background: designToken("background.page"),
|
||||
color: designToken("text.primary"),
|
||||
display: "grid",
|
||||
fontFamily: designToken("typography.family-sans"),
|
||||
gridTemplateColumns: "15rem minmax(0, 1fr)",
|
||||
gridTemplateRows: "auto minmax(0, 1fr)",
|
||||
minHeight: "100vh",
|
||||
minWidth: 0,
|
||||
width: "100%",
|
||||
},
|
||||
main: {
|
||||
boxSizing: "border-box",
|
||||
minWidth: 0,
|
||||
overflowX: "auto",
|
||||
padding: designToken("spacing.xl"),
|
||||
},
|
||||
message: {
|
||||
color: designToken("text.secondary"),
|
||||
display: "grid",
|
||||
@@ -194,23 +167,10 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
|
||||
];
|
||||
}
|
||||
|
||||
function ModuleButton(props: {
|
||||
readonly entry: DashboardNavigationEntry;
|
||||
readonly selected: boolean;
|
||||
readonly onSelect: () => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<Button
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
aria-pressed={props.selected}
|
||||
data-testid={`select-${props.entry.descriptor.id}`}
|
||||
onClick={props.onSelect}
|
||||
style={{ justifyContent: "flex-start", width: "100%" }}
|
||||
variant="quiet"
|
||||
>
|
||||
{props.entry.name}
|
||||
</Button>
|
||||
);
|
||||
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
|
||||
* not a page change. */
|
||||
function routeKey(route: DashboardRoute): string {
|
||||
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
|
||||
}
|
||||
|
||||
function HostMessage(props: {
|
||||
@@ -265,21 +225,32 @@ async function resolveBootstrapState(
|
||||
/**
|
||||
* The Ekklesia Dashboard shell and its host-owned authentication boundary.
|
||||
*
|
||||
* The login form replaces the protected application in place. There is no redirect parameter, URL
|
||||
* parser, location assignment, or client router in this shell, so the current internal Dashboard URL
|
||||
* simply remains the current URL through sign-in and no external/protocol-relative return value can
|
||||
* become a navigation sink. Modules receive the same five-field context after authentication; they
|
||||
* neither render platform login nor receive sign-in/sign-out controls.
|
||||
* The login form replaces the protected application in place. There is no redirect parameter,
|
||||
* return value, or location assignment in this shell, so the current internal Dashboard URL simply
|
||||
* remains the current URL through sign-in — a deep link opens on its own page once the person has
|
||||
* signed in — and no external/protocol-relative value can become a navigation sink. The shell only
|
||||
* ever moves between its own paths (ADR 0057). Modules receive the same context after
|
||||
* authentication; they neither render platform login nor receive sign-in/sign-out controls.
|
||||
*/
|
||||
export function HostApp(props: HostAppProps): ReactElement {
|
||||
const [state, setState] = useState<BootstrapState>({ status: "loading" });
|
||||
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
|
||||
const [bootstrapAttempt, setBootstrapAttempt] = useState(0);
|
||||
const activeTransitionRef = useRef<AbortController | undefined>(undefined);
|
||||
const revalidationRef = useRef<AbortController | undefined>(undefined);
|
||||
const generationRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const { config, importModule } = props;
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const shownRouteRef = useRef<string | undefined>(undefined);
|
||||
const { config, history, importModule, setDocumentTitle } = props;
|
||||
// Fallbacks for a composition that supplies neither; created once, so they stay stable.
|
||||
const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined));
|
||||
const [fallbackPreference] = useState(() => createMemorySidebarPreference());
|
||||
const viewport = props.viewport ?? fallbackViewport;
|
||||
const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
|
||||
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
|
||||
const readPathname = useCallback(() => history.pathname(), [history]);
|
||||
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
|
||||
const route = useMemo(() => parseDashboardPath(pathname), [pathname]);
|
||||
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
|
||||
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
|
||||
// cannot resurrect a screen the person has left.
|
||||
@@ -306,14 +277,16 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
|
||||
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
|
||||
setState(resolved);
|
||||
if (resolved.status === "ready") {
|
||||
const first = allEntries(resolved.bootstrap)[0];
|
||||
setSelectedKey(first ? moduleKey(first) : undefined);
|
||||
} else {
|
||||
setSelectedKey(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// A non-canonical spelling of a real page (`/example/`) is corrected in place, adding no
|
||||
// history entry, so what is bookmarked or shared next is the one canonical address.
|
||||
useEffect(() => {
|
||||
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
|
||||
history.replace(dashboardPath(route));
|
||||
}
|
||||
}, [history, pathname, route]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
@@ -329,7 +302,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
void bootstrapAttempt;
|
||||
const { controller, generation } = beginTransition();
|
||||
setState({ status: "loading" });
|
||||
setSelectedKey(undefined);
|
||||
void resolveBootstrapState(config, controller.signal).then((resolved) => {
|
||||
if (!isCurrent(controller, generation)) {
|
||||
return;
|
||||
@@ -381,7 +353,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
const { controller, generation } = beginTransition();
|
||||
// The authenticated subtree disappears before the request starts. That synchronously schedules
|
||||
// ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions.
|
||||
setSelectedKey(undefined);
|
||||
setState({ status: "signing-out" });
|
||||
try {
|
||||
await signOutHost(config, controller.signal);
|
||||
@@ -435,7 +406,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
const transition = beginTransition();
|
||||
transition.controller.abort();
|
||||
activeTransitionRef.current = undefined;
|
||||
setSelectedKey(undefined);
|
||||
setAuthView({ kind: "sign-in" });
|
||||
setState({ status: "anonymous" });
|
||||
}
|
||||
@@ -451,7 +421,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
const transition = beginTransition();
|
||||
transition.controller.abort();
|
||||
activeTransitionRef.current = undefined;
|
||||
setSelectedKey(undefined);
|
||||
console.error("[ekklesia] session revalidation failed");
|
||||
setState({ status: "failed" });
|
||||
})
|
||||
@@ -464,6 +433,41 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
[beginTransition, config],
|
||||
);
|
||||
|
||||
const openEntry =
|
||||
state.status === "ready" && route.kind === "module"
|
||||
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
|
||||
: undefined;
|
||||
const pageTitle =
|
||||
state.status !== "ready"
|
||||
? undefined
|
||||
: route.kind === "overview"
|
||||
? "Overview"
|
||||
: route.kind === "module"
|
||||
? (openEntry?.name ?? "This page isn’t available")
|
||||
: "Page not found";
|
||||
const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle;
|
||||
|
||||
useEffect(() => {
|
||||
if (documentTitle !== undefined) {
|
||||
setDocumentTitle?.(documentTitle);
|
||||
}
|
||||
}, [documentTitle, setDocumentTitle]);
|
||||
|
||||
// Moving to another page moves keyboard and screen-reader focus to the new page's content, as a
|
||||
// full page load would have. The first page shown after sign-in or a refresh is left alone, and a
|
||||
// canonical respelling of the same page is not a move.
|
||||
const currentRouteKey = routeKey(route);
|
||||
useEffect(() => {
|
||||
if (state.status !== "ready") {
|
||||
shownRouteRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
if (shownRouteRef.current !== undefined && shownRouteRef.current !== currentRouteKey) {
|
||||
mainRef.current?.focus();
|
||||
}
|
||||
shownRouteRef.current = currentRouteKey;
|
||||
}, [currentRouteKey, state.status]);
|
||||
|
||||
const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1);
|
||||
|
||||
if (state.status === "loading") {
|
||||
@@ -552,74 +556,37 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
}
|
||||
|
||||
const { bootstrap } = state;
|
||||
const entries = allEntries(bootstrap);
|
||||
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
|
||||
const { groups, ungrouped } = bootstrap.navigation;
|
||||
|
||||
return (
|
||||
<div data-testid="host-shell" style={shellStyles.layout}>
|
||||
<header style={shellStyles.header}>
|
||||
<div style={shellStyles.brand}>
|
||||
<span aria-hidden="true" style={shellStyles.brandMark} />
|
||||
<h1 style={shellStyles.heading}>Ekklesia</h1>
|
||||
</div>
|
||||
<Button data-testid="host-logout" onClick={() => void signOut()} size="sm">
|
||||
Sign out
|
||||
</Button>
|
||||
</header>
|
||||
<aside style={shellStyles.aside}>
|
||||
<DevicePanel config={config} />
|
||||
{entries.length === 0 && groups.length === 0 ? (
|
||||
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
|
||||
No installed module provides a user interface.
|
||||
</p>
|
||||
) : (
|
||||
<nav aria-label="Modules" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||
{groups.map((group) => (
|
||||
<section data-testid={`group-${group.id}`} key={group.id}>
|
||||
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
|
||||
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||
{group.modules.map((entry) => (
|
||||
<ModuleButton
|
||||
entry={entry}
|
||||
key={moduleKey(entry)}
|
||||
onSelect={() => setSelectedKey(moduleKey(entry))}
|
||||
selected={moduleKey(entry) === selectedKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{ungrouped.length > 0 ? (
|
||||
<section data-testid="group-ungrouped">
|
||||
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
|
||||
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||
{ungrouped.map((entry) => (
|
||||
<ModuleButton
|
||||
entry={entry}
|
||||
key={moduleKey(entry)}
|
||||
onSelect={() => setSelectedKey(moduleKey(entry))}
|
||||
selected={moduleKey(entry) === selectedKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : undefined}
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
<main style={shellStyles.main}>
|
||||
{selected ? (
|
||||
<ShellLayout
|
||||
config={config}
|
||||
history={history}
|
||||
mainRef={mainRef}
|
||||
navigation={bootstrap.navigation}
|
||||
onSignOut={() => void signOut()}
|
||||
pageTitle={pageTitle}
|
||||
route={route}
|
||||
sidebarPreference={sidebarPreference}
|
||||
viewport={viewport}
|
||||
>
|
||||
{route.kind === "overview" ? (
|
||||
<OverviewPage />
|
||||
) : route.kind === "module" ? (
|
||||
openEntry ? (
|
||||
<ModuleUiFrame
|
||||
config={config}
|
||||
descriptor={selected.descriptor}
|
||||
descriptor={openEntry.descriptor}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
key={moduleKey(selected)}
|
||||
key={moduleKey(openEntry)}
|
||||
onAuthorizationResponse={onAuthorizationResponse}
|
||||
principal={bootstrap.principal}
|
||||
/>
|
||||
) : undefined}
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
<ModuleUnavailablePage history={history} />
|
||||
)
|
||||
) : (
|
||||
<NotFoundPage history={history} />
|
||||
)}
|
||||
</ShellLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user