Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd0287537e | ||
|
|
8571e6854c | ||
|
|
89ef4b5ff6 | ||
|
|
34692ac05f | ||
|
|
d42b64a093 | ||
|
|
e3e2a901a7 | ||
|
|
5a7d44781b | ||
|
|
651efa8829 | ||
|
|
cf2f847c8c | ||
|
|
af31724cb4 | ||
|
|
f3b7e19651 | ||
|
|
ba60f70835 | ||
|
|
9afe9376c4 | ||
|
|
44430b5d59 | ||
|
|
bef516796e | ||
|
|
9bef1d9ef2 | ||
|
|
f2fc069e66 | ||
|
|
0552442c7c | ||
|
|
63fda0f8d6 | ||
|
|
94fa7a17a8 |
@@ -108,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
|
||||
|
||||
|
||||
@@ -822,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
|
||||
public Website remain separate future designs.
|
||||
|
||||
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
||||
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
||||
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing
|
||||
(since added; see the Stage 1 paragraph below),
|
||||
and no module-management, settings, or storage UI — module enablement and configuration are an API and
|
||||
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
|
||||
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
|
||||
@@ -905,6 +906,59 @@ 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.
|
||||
|
||||
The Dashboard follows the operating system's **Light or Dark** appearance and **reduced-motion**
|
||||
setting, live and without a reload, and so does every module's UI, through the same design tokens
|
||||
([ADR 0060](docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md)). The dark theme is
|
||||
designed rather than inverted, every text and focus pairing is contrast-tested to WCAG AA in both
|
||||
schemes, and corners scale from one `radius.scale` token. Displays stay light.
|
||||
|
||||
**Settings** has its own place in the sidebar
|
||||
([ADR 0061](docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)): a
|
||||
landing page at `/settings`; the platform's General, Branding, Displays, Discover, and System; and each
|
||||
module's settings at `/settings/<module-id>`. Discover is where modules are found, installed, managed,
|
||||
and updated (`/settings/discover/browse`, `/installed`, `/updates`); configuring a module is its own
|
||||
Settings page. A person sees only what the server says they may administer, and no Settings link at all
|
||||
when that is nothing. The pages are placeholders until each section is built.
|
||||
|
||||
**Your account** is at the bottom of the sidebar, showing your name, email, and initials
|
||||
([ADR 0062](docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
|
||||
`/account` shows who you are signed in as. On `/account/appearance` you choose **System, Light, or
|
||||
Dark** and **reduced motion** (System, Reduce, or Allow). A choice takes effect at once, is saved to
|
||||
your account so it follows you to every device, and is applied before the first paint on your next
|
||||
visit. Managing your own appearance needs no administrative permission. Profile editing and an
|
||||
avatar image are not part of this.
|
||||
|
||||
## Quick start
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
@@ -960,7 +1014,7 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
6 permissions registered
|
||||
7 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
@@ -970,7 +1024,7 @@ Data definitions
|
||||
✓ Schemas validated
|
||||
0 modules · 0 tables
|
||||
✓ Migrations validated
|
||||
7 migrations · checksums valid
|
||||
9 migrations · checksums valid
|
||||
core:0000_identity_core_tables · f8f2086ef06f
|
||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||
core:0002_authorization_integrity · 634294731180
|
||||
@@ -978,6 +1032,8 @@ Data definitions
|
||||
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
||||
core:0005_module_configuration · c8d57f24f84b
|
||||
core:0006_device_identity · 1db97b7e3ac5
|
||||
core:0007_verified_bootstrap_owners · 49013062f3e0
|
||||
core:0008_user_preferences · fa9d4b0bb9d3
|
||||
|
||||
PostgreSQL
|
||||
✓ Database TLS verified
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@ekklesia/design-tokens": "workspace:*",
|
||||
"@ekklesia/module-ui-host": "workspace:*",
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"@ekklesia/typescript-config": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
|
||||
import {
|
||||
type AccountPreferences,
|
||||
type AccountStore,
|
||||
AccountStoreError,
|
||||
parseAccountPreferencesUpdate,
|
||||
} from "@ekklesia/core/account";
|
||||
import { CORE_ACCOUNT_SELF_MANAGE } from "@ekklesia/core/authorization";
|
||||
import { type Context, Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
|
||||
export const ACCOUNT_PATH = "/api/v1/account";
|
||||
export const ACCOUNT_PREFERENCES_PATH = `${ACCOUNT_PATH}/preferences`;
|
||||
|
||||
/** `GET /api/v1/account`: the signed-in person's own account, as the account area shows it. */
|
||||
export interface AccountResponse {
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly preferences: AccountPreferences;
|
||||
}
|
||||
|
||||
export interface CreateAccountRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
readonly store: AccountStore;
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. */
|
||||
function storeFailure(
|
||||
c: Context,
|
||||
error: unknown,
|
||||
fallback: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED",
|
||||
): Response {
|
||||
if (error instanceof AccountStoreError) {
|
||||
if (error.code === "ACCOUNT_NOT_FOUND") {
|
||||
return sanitizedFailure(c, 404, error.code);
|
||||
}
|
||||
if (error.code === "ACCOUNT_PREFERENCES_INVALID") {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
return sanitizedFailure(c, 503, error.code);
|
||||
}
|
||||
return sanitizedFailure(c, 503, fallback);
|
||||
}
|
||||
|
||||
async function readJsonBody(c: Context): Promise<unknown> {
|
||||
try {
|
||||
return (await c.req.json()) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed-in person's own account (ADR 0062): who they are signed in as, and their own
|
||||
* preferences.
|
||||
*
|
||||
* ## Self-only by construction
|
||||
*
|
||||
* Neither route accepts a user id — not in the path, not in the query, not in the body. The account
|
||||
* is always the one the **session** names, so there is no request that could read or change anyone
|
||||
* else's, whatever permission the caller holds. A body that names any key besides a known
|
||||
* preference is refused whole.
|
||||
*
|
||||
* ## Authorization
|
||||
*
|
||||
* Both routes require `core.account.self.manage`, whose baseline is `authenticated`: every
|
||||
* signed-in person may manage their own account, and needs no church-management permission to do
|
||||
* so. An anonymous caller and a paired device are refused (`403`). Changing a preference is a
|
||||
* mutation like any other Core mutation: an exact trusted `Origin` and a bounded body are checked
|
||||
* before authorization work begins.
|
||||
*
|
||||
* ## What the browser learns
|
||||
*
|
||||
* Name, email, and preferences — exactly what the account area shows, and nothing else. Not the
|
||||
* account's image URL, verification state, timestamps, or any credential. Responses are per-person
|
||||
* and never cached.
|
||||
*/
|
||||
export function createAccountRoutes(options: CreateAccountRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const requireSelf = requirePermission(options, CORE_ACCOUNT_SELF_MANAGE);
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||
|
||||
routes.get(ACCOUNT_PATH, requireSelf, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
// Unreachable through the `authenticated` baseline, which only a person satisfies; checked
|
||||
// again so the account read can never be keyed by anything but a person's own session.
|
||||
if (!isAuthenticatedPrincipal(principal)) {
|
||||
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
try {
|
||||
const account = await options.store.readAccount(principal.userId);
|
||||
if (!account) {
|
||||
return sanitizedFailure(c, 404, "ACCOUNT_NOT_FOUND");
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
email: account.email,
|
||||
name: account.name,
|
||||
preferences: {
|
||||
appearance: account.preferences.appearance,
|
||||
motion: account.preferences.motion,
|
||||
},
|
||||
} satisfies AccountResponse,
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error, "ACCOUNT_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
routes.patch(
|
||||
ACCOUNT_PREFERENCES_PATH,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireSelf,
|
||||
async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
if (!isAuthenticatedPrincipal(principal)) {
|
||||
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
const update = parseAccountPreferencesUpdate(await readJsonBody(c));
|
||||
if (!update) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
try {
|
||||
const preferences = await options.store.updatePreferences(principal.userId, update);
|
||||
return c.json(
|
||||
{
|
||||
appearance: preferences.appearance,
|
||||
motion: preferences.motion,
|
||||
} satisfies AccountPreferences,
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error, "ACCOUNT_WRITE_FAILED");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
+46
-4
@@ -8,6 +8,7 @@ import type {
|
||||
AuthorizationStore,
|
||||
PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { AccountStore } from "@ekklesia/core/account";
|
||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
@@ -28,9 +29,11 @@ import type { Context, MiddlewareHandler } from "hono";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import { createAccountRoutes } from "./account-routes.js";
|
||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createDashboardSettingsRoutes } from "./dashboard-settings-routes.js";
|
||||
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
@@ -140,6 +143,10 @@ export interface CreateApiAppOptions {
|
||||
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||
* ordering, module startup, or any authorization decision. */
|
||||
readonly dashboard: DashboardStore;
|
||||
/** Core's persistence for a signed-in person's own account summary and preferences (ADR 0062).
|
||||
* Required like `dashboard`: every installation has accounts. Reachable only through routes that
|
||||
* take the account from the session, never from the request. */
|
||||
readonly account: AccountStore;
|
||||
/**
|
||||
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
|
||||
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
|
||||
@@ -363,10 +370,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(
|
||||
"/",
|
||||
@@ -447,6 +462,33 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
}),
|
||||
);
|
||||
|
||||
// Which Settings destinations the viewer may see (ADR 0061): server-evaluated presentation hints
|
||||
// beside the navigation, never authority. See `./dashboard-settings-routes.js`.
|
||||
app.route(
|
||||
"/",
|
||||
createDashboardSettingsRoutes({
|
||||
evaluator: options.authorization.evaluator,
|
||||
platformOwners: options.authorization.store,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
snapshot: options.moduleLifecycle.snapshot,
|
||||
}),
|
||||
);
|
||||
|
||||
// The signed-in person's own account and preferences (ADR 0062). Self-only by construction: the
|
||||
// account is always the session's, and no request value can name another. See
|
||||
// `./account-routes.js`.
|
||||
app.route(
|
||||
"/",
|
||||
createAccountRoutes({
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
store: options.account,
|
||||
trustedOrigins: allowedOrigins,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
|
||||
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
|
||||
// rather than inside it. See `./module-lifecycle-routes.js`.
|
||||
|
||||
@@ -3,7 +3,9 @@ import { readFile } from "node:fs/promises";
|
||||
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||
|
||||
/**
|
||||
* The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
|
||||
* The three immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044):
|
||||
* the page, its application script, and the small pre-paint appearance script the page loads in
|
||||
* `<head>` (ADR 0062).
|
||||
*
|
||||
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
|
||||
* from the exact URLs the surface package exports. Request input is never involved in this read, the
|
||||
@@ -16,6 +18,8 @@ export interface DashboardHostAssets {
|
||||
readonly htmlEtag: string;
|
||||
readonly script: string;
|
||||
readonly scriptEtag: string;
|
||||
readonly appearanceScript: string;
|
||||
readonly appearanceScriptEtag: string;
|
||||
}
|
||||
|
||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||
@@ -42,11 +46,21 @@ function strongEtag(text: string): string {
|
||||
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
|
||||
* valid page that can never render anything.
|
||||
*/
|
||||
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||
export function createDashboardHostAssets(
|
||||
html: string,
|
||||
script: string,
|
||||
appearanceScript: string,
|
||||
): DashboardHostAssets {
|
||||
if (
|
||||
html.trim().length === 0 ||
|
||||
script.trim().length === 0 ||
|
||||
appearanceScript.trim().length === 0
|
||||
) {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
return Object.freeze({
|
||||
appearanceScript,
|
||||
appearanceScriptEtag: strongEtag(appearanceScript),
|
||||
html,
|
||||
htmlEtag: strongEtag(html),
|
||||
script,
|
||||
@@ -92,7 +106,7 @@ export function renderDashboardPage(html: string, apiOrigin: string): string {
|
||||
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
|
||||
}
|
||||
|
||||
/** Loads only the two surface-declared files, and renders the API origin into the page. A missing,
|
||||
/** Loads only the three surface-declared files, and renders the API origin into the page. A missing,
|
||||
* unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers
|
||||
* `/` with a 404 or a page that cannot find its API. */
|
||||
export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> {
|
||||
@@ -101,7 +115,12 @@ export async function loadDashboardHostAssets(apiOrigin: string): Promise<Dashbo
|
||||
// rejected sibling read is left running after this function has already failed.
|
||||
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||
return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script);
|
||||
const appearanceScript = await readFile(new URL(dashboardHostAssetUrls.appearance), "utf8");
|
||||
return createDashboardHostAssets(
|
||||
renderDashboardPage(html, apiOrigin),
|
||||
script,
|
||||
appearanceScript,
|
||||
);
|
||||
} catch {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
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";
|
||||
/** The pre-paint appearance script (ADR 0062), beside the application script. */
|
||||
export const DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH = "/assets/ekklesia-dashboard/appearance.js";
|
||||
|
||||
/**
|
||||
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
|
||||
@@ -50,8 +58,12 @@ export class DashboardHostConfigurationError extends Error {
|
||||
* a composition can never pair bytes with a validator computed from different bytes. */
|
||||
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
|
||||
try {
|
||||
const snapshot = createDashboardHostAssets(input.html, input.script);
|
||||
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
|
||||
const snapshot = createDashboardHostAssets(input.html, input.script, input.appearanceScript);
|
||||
if (
|
||||
snapshot.htmlEtag !== input.htmlEtag ||
|
||||
snapshot.scriptEtag !== input.scriptEtag ||
|
||||
snapshot.appearanceScriptEtag !== input.appearanceScriptEtag
|
||||
) {
|
||||
throw new DashboardHostConfigurationError();
|
||||
}
|
||||
return snapshot;
|
||||
@@ -85,30 +97,74 @@ function fixedAsset(
|
||||
});
|
||||
}
|
||||
|
||||
export interface DashboardHostRouteOptions {
|
||||
/**
|
||||
* The id of every module this process discovered that declares a Dashboard UI — enabled or not,
|
||||
* since a disabled module's bookmark should open the Dashboard and say the module is unavailable
|
||||
* rather than fail as an unknown address. Fixed at startup, like the rest of the module set.
|
||||
*/
|
||||
readonly modulePages: readonly string[];
|
||||
}
|
||||
|
||||
/** Each module page must be a real module id that names no platform namespace, exactly once.
|
||||
* Discovery already guarantees this; the routes refuse to compose rather than trust it. */
|
||||
function validatedModulePages(modulePages: readonly string[]): readonly string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const moduleId of modulePages) {
|
||||
if (
|
||||
!moduleIdSchema.safeParse(moduleId).success ||
|
||||
isReservedModuleId(moduleId) ||
|
||||
DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) ||
|
||||
seen.has(moduleId)
|
||||
) {
|
||||
throw new DashboardHostConfigurationError();
|
||||
}
|
||||
seen.add(moduleId);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
||||
* 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(
|
||||
c.req.header("if-none-match"),
|
||||
@@ -119,5 +175,17 @@ export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
|
||||
),
|
||||
);
|
||||
|
||||
// Same-origin, revalidated like the application script, and carrying nothing but code: the
|
||||
// person's cached choice lives in their browser and is read by this script, never served by it.
|
||||
routes.get(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, (c) =>
|
||||
fixedAsset(
|
||||
c.req.header("if-none-match"),
|
||||
snapshot.appearanceScript,
|
||||
snapshot.appearanceScriptEtag,
|
||||
"text/javascript; charset=utf-8",
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationPlatformOwnerPort,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { ModuleLifecycleSnapshot } from "@ekklesia/core/modules";
|
||||
import { SETTINGS_SECTIONS, type SettingsSection } from "@ekklesia/shell/dashboard-routes";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
} from "./authorization-middleware.js";
|
||||
|
||||
export const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings";
|
||||
|
||||
/** One module's settings destination: its id (the `/settings/<module-id>` segment), its display
|
||||
* name, and whether it is running. A disabled or blocked module is listed so its address can say it
|
||||
* is unavailable, and flagged so the Dashboard never offers it in navigation. */
|
||||
export interface DashboardSettingsModuleView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly available: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardSettingsResponse {
|
||||
/** The platform sections this viewer may see, in navigation order. */
|
||||
readonly sections: readonly SettingsSection[];
|
||||
/** Modules whose settings this viewer may see, by name. */
|
||||
readonly modules: readonly DashboardSettingsModuleView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may see each platform section (ADR 0061).
|
||||
*
|
||||
* A section whose administration already has a Core permission uses it, so it is delegable today.
|
||||
* General, Branding, and System have no permission yet — nothing in them exists to authorize — so
|
||||
* they are shown only to the **platform owner**, the one principal who already holds every
|
||||
* permission; each gains its own delegable permission in the work that builds it.
|
||||
*/
|
||||
const SECTION_ACCESS: Readonly<Record<SettingsSection, PermissionId | "platform-owner">> =
|
||||
Object.freeze({
|
||||
branding: "platform-owner",
|
||||
// Finding, installing, turning on and off, and updating modules: deciding which modules run.
|
||||
discover: CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
displays: CORE_DEVICES_MANAGE,
|
||||
general: "platform-owner",
|
||||
system: "platform-owner",
|
||||
});
|
||||
|
||||
export interface CreateDashboardSettingsRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The boot snapshot: every module this process discovered, and which it started. A module's
|
||||
* settings exist when its manifest declares configuration (ADR 0029). */
|
||||
readonly snapshot: ModuleLifecycleSnapshot;
|
||||
readonly platformOwners: AuthorizationPlatformOwnerPort;
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Dashboard's Settings navigation, evaluated for the viewer.
|
||||
*
|
||||
* Like the module permission hints in the navigation (ADR 0026), these are **presentation hints,
|
||||
* not authority**: they decide which Settings destinations the Dashboard offers, so nobody is shown
|
||||
* administration they cannot carry out (DESIGN.md §9.2). Every route behind a section keeps its own
|
||||
* enforcement, unchanged. Each answer goes through Core's one evaluator — baselines, grants, expiry,
|
||||
* and the owner bypass behave exactly as on a real request — and an evaluation failure is a `503`,
|
||||
* never an empty answer that would look like a permissions problem.
|
||||
*
|
||||
* Reading requires `core.modules.ui.read`, the same permission the navigation it sits beside
|
||||
* requires: any signed-in person may ask, and most will be told there is nothing for them.
|
||||
*/
|
||||
export function createDashboardSettingsRoutes(options: CreateDashboardSettingsRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const requireRead = requirePermission(options, CORE_MODULES_UI_READ);
|
||||
const started = new Set(options.snapshot.startedModuleIds);
|
||||
const modulesWithSettings = options.snapshot.discoveredModules
|
||||
.filter((module) => module.manifest.configuration.length > 0)
|
||||
.map((module) => ({ id: module.manifest.id, name: module.manifest.name }))
|
||||
.sort((left, right) => compareText(left.name, right.name) || compareText(left.id, right.id));
|
||||
|
||||
const holds = async (
|
||||
principal: EkklesiaPrincipal,
|
||||
permission: PermissionId,
|
||||
): Promise<boolean> => {
|
||||
const declaration = options.registry.get(permission);
|
||||
if (!declaration) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
await options.evaluator.authorize({
|
||||
baseline: declaration.baseline,
|
||||
permission,
|
||||
principal,
|
||||
})
|
||||
).allowed;
|
||||
};
|
||||
|
||||
routes.get(DASHBOARD_SETTINGS_PATH, requireRead, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
try {
|
||||
let owner: boolean | undefined;
|
||||
const isOwner = async (): Promise<boolean> => {
|
||||
owner ??=
|
||||
principal.kind === "authenticated" &&
|
||||
(await options.platformOwners.isPlatformOwner(principal.userId));
|
||||
return owner;
|
||||
};
|
||||
const sections: SettingsSection[] = [];
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
const access = SECTION_ACCESS[section];
|
||||
if (access === "platform-owner" ? await isOwner() : await holds(principal, access)) {
|
||||
sections.push(section);
|
||||
}
|
||||
}
|
||||
const modules = (await holds(principal, CORE_MODULES_CONFIGURATION_MANAGE))
|
||||
? modulesWithSettings.map((module) => ({ ...module, available: started.has(module.id) }))
|
||||
: [];
|
||||
return c.json({ modules, sections } satisfies DashboardSettingsResponse, 200);
|
||||
} catch {
|
||||
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||
}
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
||||
import { type AccountDrizzleDatabase, createAccountDrizzleView } from "@ekklesia/core/account";
|
||||
import {
|
||||
type AuthorizationDrizzleDatabase,
|
||||
createAuthorizationDrizzleView,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
export interface ApiDatabaseHandle {
|
||||
readonly account: AccountDrizzleDatabase;
|
||||
readonly authorization: AuthorizationDrizzleDatabase;
|
||||
readonly devices: DeviceDrizzleDatabase;
|
||||
readonly dashboard: DashboardDrizzleDatabase;
|
||||
@@ -74,6 +76,7 @@ export function createApiDatabase(
|
||||
const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
|
||||
|
||||
return Object.freeze({
|
||||
account: createAccountDrizzleView(pool),
|
||||
authorization: createAuthorizationDrizzleView(pool),
|
||||
close,
|
||||
devices: createDeviceDrizzleView(pool),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createAuthorizationEvaluator,
|
||||
createAuthorizationStore,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { createAccountStore } from "@ekklesia/core/account";
|
||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
||||
@@ -639,6 +640,8 @@ export async function startApi(
|
||||
},
|
||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||
// The same pool again: a person's own account summary and preferences (ADR 0062).
|
||||
account: createAccountStore(databaseHandle.account),
|
||||
dashboardHostAssets,
|
||||
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
||||
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
createDevicePrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import { ACCOUNT_APPEARANCES, ACCOUNT_MOTION_PREFERENCES } from "@ekklesia/core/account";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { EKKLESIA_APPEARANCES, EKKLESIA_MOTION_PREFERENCES } from "@ekklesia/design-tokens";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ACCOUNT_PATH, ACCOUNT_PREFERENCES_PATH } from "../src/account-routes.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
createInMemoryAccountStore,
|
||||
type InMemoryAccountPerson,
|
||||
} from "./support/in-memory-account-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.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";
|
||||
|
||||
/**
|
||||
* `GET /api/v1/account` and `PATCH /api/v1/account/preferences` (ADR 0062): a signed-in person's
|
||||
* own account and preferences, self-only by construction.
|
||||
*/
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const adaId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||
const graceId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222");
|
||||
const ada = createAuthenticatedPrincipal(adaId);
|
||||
|
||||
const people: readonly InMemoryAccountPerson[] = [
|
||||
{ email: "[email protected]", name: "Ada Lovelace", userId: adaId },
|
||||
{
|
||||
email: "[email protected]",
|
||||
name: "Grace Hopper",
|
||||
preferences: { appearance: "light", motion: "reduce" },
|
||||
userId: graceId,
|
||||
},
|
||||
];
|
||||
|
||||
interface Options {
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly evaluationFails?: boolean;
|
||||
}
|
||||
|
||||
function build(options: Options = {}) {
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async () => false },
|
||||
platformOwners: {
|
||||
isPlatformOwner: async () => {
|
||||
if (options.evaluationFails) {
|
||||
throw new Error("database down");
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ada),
|
||||
};
|
||||
const account = createInMemoryAccountStore(people);
|
||||
const app = createApiApp({
|
||||
account,
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: composePlatformPermissionRegistry([]),
|
||||
store: {} as unknown as AuthorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor([], { modules: [] }),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
return { account, app };
|
||||
}
|
||||
|
||||
function patch(body: unknown, init: { origin?: string | null; raw?: string } = {}): RequestInit {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (init.origin !== null) {
|
||||
headers.Origin = init.origin ?? trustedOrigin;
|
||||
}
|
||||
return { body: init.raw ?? JSON.stringify(body), headers, method: "PATCH" };
|
||||
}
|
||||
|
||||
describe("core.account.self.manage", () => {
|
||||
it("is an authenticated-baseline Core permission, so managing your own account needs no grant", () => {
|
||||
expect(CORE_PERMISSION_REGISTRY.get(CORE_ACCOUNT_SELF_MANAGE)?.baseline).toBe("authenticated");
|
||||
});
|
||||
|
||||
it("holds Core's preference values equal to the design tokens' own", () => {
|
||||
expect([...ACCOUNT_APPEARANCES]).toEqual([...EKKLESIA_APPEARANCES]);
|
||||
expect([...ACCOUNT_MOTION_PREFERENCES]).toEqual([...EKKLESIA_MOTION_PREFERENCES]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/account", () => {
|
||||
it("returns the signed-in person's own name, email, and default preferences", async () => {
|
||||
const { app } = build();
|
||||
const response = await app.request(ACCOUNT_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("vary")).toContain("Cookie");
|
||||
expect(await response.json()).toEqual({
|
||||
email: "[email protected]",
|
||||
name: "Ada Lovelace",
|
||||
preferences: { appearance: "system", motion: "system" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns exactly name, email, and preferences — nothing else about the account", async () => {
|
||||
const { app } = build();
|
||||
const body = (await (await app.request(ACCOUNT_PATH)).json()) as Record<string, unknown>;
|
||||
expect(Object.keys(body).sort()).toEqual(["email", "name", "preferences"]);
|
||||
expect(Object.keys(body.preferences as object).sort()).toEqual(["appearance", "motion"]);
|
||||
});
|
||||
|
||||
it("refuses an anonymous caller", async () => {
|
||||
const { account, app } = build({ principal: ANONYMOUS_PRINCIPAL });
|
||||
const response = await app.request(ACCOUNT_PATH);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(account.requestedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a paired display device, which has no account", async () => {
|
||||
const { account, app } = build({
|
||||
principal: createDevicePrincipal("33333333-3333-4333-8333-333333333333"),
|
||||
});
|
||||
const response = await app.request(ACCOUNT_PATH);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(account.requestedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads only the session's account, whatever the request names", async () => {
|
||||
const { account, app } = build();
|
||||
const response = await app.request(`${ACCOUNT_PATH}?userId=${graceId}&id=${graceId}`, {
|
||||
headers: { "X-User-Id": graceId },
|
||||
});
|
||||
|
||||
expect(((await response.json()) as { name: string }).name).toBe("Ada Lovelace");
|
||||
expect(account.requestedUserIds).toEqual([adaId]);
|
||||
});
|
||||
|
||||
it("has no route that addresses another person's account", async () => {
|
||||
const { account, app } = build();
|
||||
for (const path of [`${ACCOUNT_PATH}/${graceId}`, `${ACCOUNT_PREFERENCES_PATH}/${graceId}`]) {
|
||||
expect((await app.request(path)).status).toBe(404);
|
||||
expect((await app.request(path, patch({ appearance: "dark" }))).status).toBe(404);
|
||||
}
|
||||
expect(account.requestedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("is not found when the session's person no longer exists", async () => {
|
||||
const { app } = build({
|
||||
principal: createAuthenticatedPrincipal("99999999-9999-4999-8999-999999999999"),
|
||||
});
|
||||
const response = await app.request(ACCOUNT_PATH);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "ACCOUNT_NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("reports a storage failure as a sanitized 503", async () => {
|
||||
const { account, app } = build();
|
||||
account.failWith("ACCOUNT_READ_FAILED");
|
||||
const response = await app.request(ACCOUNT_PATH);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "ACCOUNT_READ_FAILED" });
|
||||
});
|
||||
|
||||
it("reports an authorization outage as a 503, never as a denial", async () => {
|
||||
// The authenticated baseline needs no owner lookup, so an owner-lookup outage cannot deny it.
|
||||
const { app } = build({ evaluationFails: true });
|
||||
expect((await app.request(ACCOUNT_PATH)).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/v1/account/preferences", () => {
|
||||
it.each([
|
||||
["appearance", "light"],
|
||||
["appearance", "dark"],
|
||||
["appearance", "system"],
|
||||
["motion", "reduce"],
|
||||
["motion", "allow"],
|
||||
["motion", "system"],
|
||||
] as const)("saves %s %s and returns everything in effect", async (name, value) => {
|
||||
const { app } = build();
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ [name]: value }));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
const body = (await response.json()) as Record<string, string>;
|
||||
expect(body[name]).toBe(value);
|
||||
expect(Object.keys(body).sort()).toEqual(["appearance", "motion"]);
|
||||
|
||||
const read = (await (await app.request(ACCOUNT_PATH)).json()) as {
|
||||
preferences: Record<string, string>;
|
||||
};
|
||||
expect(read.preferences[name]).toBe(value);
|
||||
});
|
||||
|
||||
it("changes only the preference named", async () => {
|
||||
const { app } = build();
|
||||
await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ motion: "reduce" }));
|
||||
|
||||
expect(await response.json()).toEqual({ appearance: "dark", motion: "reduce" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty object", {}],
|
||||
["an unknown appearance", { appearance: "sepia" }],
|
||||
["an unknown motion", { motion: "none" }],
|
||||
["an unknown key", { appearance: "dark", radius: "large" }],
|
||||
["another person's id", { appearance: "dark", userId: "22222222-2222-4222-8222-222222222222" }],
|
||||
["an array", ["dark"]],
|
||||
["null", null],
|
||||
])("refuses %s with nothing changed", async (_label, body) => {
|
||||
const { account, app } = build();
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch(body));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(account.requestedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a body that is not JSON", async () => {
|
||||
const { app } = build();
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch(undefined, { raw: "{" }));
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("refuses an untrusted or missing Origin before anything else", async () => {
|
||||
const { account, app } = build();
|
||||
for (const origin of ["https://elsewhere.example.test", null] as const) {
|
||||
const response = await app.request(
|
||||
ACCOUNT_PREFERENCES_PATH,
|
||||
patch({ appearance: "dark" }, { origin }),
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
}
|
||||
expect(account.requestedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses an oversized body", async () => {
|
||||
const { app } = build();
|
||||
const response = await app.request(
|
||||
ACCOUNT_PREFERENCES_PATH,
|
||||
patch(undefined, { raw: JSON.stringify({ appearance: "dark", pad: "x".repeat(20_000) }) }),
|
||||
);
|
||||
expect(response.status).toBe(413);
|
||||
});
|
||||
|
||||
it("refuses an anonymous caller and a device", async () => {
|
||||
for (const principal of [
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createDevicePrincipal("33333333-3333-4333-8333-333333333333"),
|
||||
]) {
|
||||
const { account, app } = build({ principal });
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
|
||||
expect(response.status).toBe(403);
|
||||
expect(account.requestedUserIds).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("changes only the session's own preferences, never another person's", async () => {
|
||||
const { account, app } = build();
|
||||
await app.request(
|
||||
`${ACCOUNT_PREFERENCES_PATH}?userId=${graceId}`,
|
||||
patch({ appearance: "dark", motion: "allow" }),
|
||||
);
|
||||
|
||||
expect(account.requestedUserIds).toEqual([adaId]);
|
||||
expect((await account.readAccount(graceId))?.preferences).toEqual({
|
||||
appearance: "light",
|
||||
motion: "reduce",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a storage failure as a sanitized 503", async () => {
|
||||
const { account, app } = build();
|
||||
account.failWith("ACCOUNT_WRITE_FAILED");
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "ACCOUNT_WRITE_FAILED" });
|
||||
});
|
||||
|
||||
it("answers a preflight from the Dashboard origin with PATCH allowed", async () => {
|
||||
const { app } = build();
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, {
|
||||
headers: {
|
||||
"Access-Control-Request-Method": "PATCH",
|
||||
Origin: trustedOrigin,
|
||||
},
|
||||
method: "OPTIONS",
|
||||
});
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-methods")).toContain("PATCH");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -237,6 +260,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
@@ -1349,7 +1373,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);
|
||||
@@ -1498,6 +1522,162 @@ describe.sequential("live API — exported browser auth shell against PostgreSQL
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — a person's own account and preferences (ADR 0062)", () => {
|
||||
let database: { name: string; url: string };
|
||||
let running: RunningApi;
|
||||
let origin: string;
|
||||
let ada: { cookie: string; userId: string };
|
||||
let grace: { cookie: string; userId: string };
|
||||
const password = "correct horse battery staple";
|
||||
|
||||
async function account(cookie?: string): Promise<{ status: number; body: unknown }> {
|
||||
const response = await fetch(`${origin}/api/v1/account`, {
|
||||
headers: { origin: dashboardOrigin, ...(cookie ? { cookie } : {}) },
|
||||
});
|
||||
return { body: await response.json(), status: response.status };
|
||||
}
|
||||
|
||||
async function changePreferences(
|
||||
cookie: string | undefined,
|
||||
body: unknown,
|
||||
options: { readonly origin?: string; readonly query?: string } = {},
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const response = await fetch(`${origin}/api/v1/account/preferences${options.query ?? ""}`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: options.origin ?? dashboardOrigin,
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
method: "PATCH",
|
||||
});
|
||||
return { body: await response.json(), status: response.status };
|
||||
}
|
||||
|
||||
async function person(email: string): Promise<{ cookie: string; userId: string }> {
|
||||
const created = await signUp(origin, email, password);
|
||||
expect(created.response.status).toBe(200);
|
||||
const session = await signIn(origin, email, password);
|
||||
if (!session.cookiePair) {
|
||||
throw harnessError("The account session cookie was not issued.");
|
||||
}
|
||||
return { cookie: session.cookiePair, userId: created.body.user.id };
|
||||
}
|
||||
|
||||
it("starts a freshly migrated API with two ordinary accounts", async () => {
|
||||
database = await createDisposableDatabase("account_prefs");
|
||||
await applyCoreMigrations(database.url);
|
||||
running = await startMigratedApi(database.url);
|
||||
origin = apiOrigin(running);
|
||||
ada = await person("[email protected]");
|
||||
grace = await person("[email protected]");
|
||||
});
|
||||
|
||||
it("shows each person their own name, email, and the defaults, with no grant", async () => {
|
||||
expect(await account(ada.cookie)).toEqual({
|
||||
body: {
|
||||
email: "[email protected]",
|
||||
name: "Integration Test User",
|
||||
preferences: { appearance: "system", motion: "system" },
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
expect(((await account(grace.cookie)).body as { email: string }).email).toBe(
|
||||
"[email protected]",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an anonymous caller both routes", async () => {
|
||||
expect((await account()).status).toBe(403);
|
||||
expect((await changePreferences(undefined, { appearance: "dark" })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("saves each person's change to their own account only", async () => {
|
||||
expect(await changePreferences(ada.cookie, { appearance: "dark" })).toEqual({
|
||||
body: { appearance: "dark", motion: "system" },
|
||||
status: 200,
|
||||
});
|
||||
expect(await changePreferences(ada.cookie, { motion: "reduce" })).toEqual({
|
||||
body: { appearance: "dark", motion: "reduce" },
|
||||
status: 200,
|
||||
});
|
||||
expect((await account(grace.cookie)).body).toMatchObject({
|
||||
preferences: { appearance: "system", motion: "system" },
|
||||
});
|
||||
});
|
||||
|
||||
it("cannot be pointed at another person's account by the query or the body", async () => {
|
||||
const viaQuery = await changePreferences(
|
||||
ada.cookie,
|
||||
{ appearance: "light" },
|
||||
{ query: `?userId=${grace.userId}` },
|
||||
);
|
||||
expect(viaQuery).toEqual({ body: { appearance: "light", motion: "reduce" }, status: 200 });
|
||||
|
||||
const viaBody = await changePreferences(ada.cookie, {
|
||||
appearance: "dark",
|
||||
userId: grace.userId,
|
||||
});
|
||||
expect(viaBody).toEqual({ body: { code: "INVALID_REQUEST" }, status: 400 });
|
||||
|
||||
expect((await account(grace.cookie)).body).toMatchObject({
|
||||
preferences: { appearance: "system", motion: "system" },
|
||||
});
|
||||
expect((await account(ada.cookie)).body).toMatchObject({
|
||||
preferences: { appearance: "light", motion: "reduce" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an untrusted Origin and an invalid value, changing nothing", async () => {
|
||||
expect(
|
||||
await changePreferences(ada.cookie, { appearance: "dark" }, { origin: untrustedOrigin }),
|
||||
).toEqual({ body: { code: "UNTRUSTED_ORIGIN" }, status: 403 });
|
||||
expect(await changePreferences(ada.cookie, { appearance: "sepia" })).toEqual({
|
||||
body: { code: "INVALID_REQUEST" },
|
||||
status: 400,
|
||||
});
|
||||
expect((await account(ada.cookie)).body).toMatchObject({
|
||||
preferences: { appearance: "light", motion: "reduce" },
|
||||
});
|
||||
});
|
||||
|
||||
it("follows the person to a new session, as another device would see it", async () => {
|
||||
const elsewhere = await signIn(origin, "[email protected]", password);
|
||||
expect((await account(elsewhere.cookiePair as string)).body).toMatchObject({
|
||||
preferences: { appearance: "light", motion: "reduce" },
|
||||
});
|
||||
});
|
||||
|
||||
it("stores exactly one typed row, for the one person who chose something", async () => {
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
const rows = await safeQuery<{ user_id: string; appearance: string; motion: string }>(
|
||||
admin,
|
||||
"select user_id, appearance, motion from ekklesia_core_user_preference",
|
||||
);
|
||||
expect(rows).toEqual([{ appearance: "light", motion: "reduce", user_id: ada.userId }]);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a signed-out session", async () => {
|
||||
const signedOut = await fetch(`${origin}/api/auth/sign-out`, {
|
||||
body: "{}",
|
||||
headers: { "content-type": "application/json", cookie: ada.cookie, origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
expect(signedOut.status).toBe(200);
|
||||
expect((await account(ada.cookie)).status).toBe(403);
|
||||
expect((await changePreferences(ada.cookie, { appearance: "dark" })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("shuts down cleanly", async () => {
|
||||
await running.shutdown();
|
||||
await dropDatabase(database.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — startup and migration guarantees", () => {
|
||||
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
|
||||
const database = await createDisposableDatabase("unmigrated");
|
||||
@@ -2230,7 +2410,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);
|
||||
});
|
||||
@@ -2607,10 +2787,11 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
||||
expect(permissionsCheck).toMatchObject({
|
||||
// Core's own six (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// Core's own seven (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
|
||||
// `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one.
|
||||
details: ["7 permissions registered"],
|
||||
// `core.modules.configuration.manage`, `core.devices.manage`, `core.account.self.manage`)
|
||||
// plus the fixture module's one.
|
||||
details: ["8 permissions registered"],
|
||||
status: "pass",
|
||||
});
|
||||
// Doctor and permission composition read only the manifest — the fixture's register/start
|
||||
@@ -2837,7 +3018,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);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
createApiApp as createApiAppWithModules,
|
||||
} from "../src/app.js";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -111,6 +112,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -130,6 +132,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -366,6 +369,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -391,6 +395,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -418,6 +423,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -447,6 +453,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -476,6 +483,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -502,6 +510,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -530,6 +539,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -555,6 +565,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -582,6 +593,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -616,6 +628,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -651,6 +664,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -691,6 +705,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -713,6 +728,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -737,6 +753,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -760,6 +777,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -781,6 +799,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -809,6 +828,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -840,6 +860,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -874,6 +895,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -899,6 +921,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -938,6 +961,7 @@ describe("split Dashboard and API origins (ADR 0045)", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
moduleRealtime,
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
||||
dashboardHostAssetUrls: Object.freeze({
|
||||
html: "file:///surface/browser/index.html",
|
||||
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
|
||||
appearance: "file:///surface/browser/assets/ekklesia-dashboard/appearance.js",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -28,19 +29,26 @@ function sha256Etag(text: string): string {
|
||||
describe("Dashboard host asset loading", () => {
|
||||
beforeEach(() => readFileMock.mockReset());
|
||||
|
||||
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||
const script = "export const dashboardHost = true;\n";
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||
const appearanceScript = "(() => {})();\n";
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(html)
|
||||
.mockResolvedValueOnce(script)
|
||||
.mockResolvedValueOnce(appearanceScript);
|
||||
|
||||
const assets = await loadDashboardHostAssets(API_ORIGIN);
|
||||
|
||||
expect(readFileMock.mock.calls).toEqual([
|
||||
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||
[new URL("file:///surface/browser/assets/ekklesia-dashboard/appearance.js"), "utf8"],
|
||||
]);
|
||||
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
||||
expect(assets).toEqual({
|
||||
appearanceScript,
|
||||
appearanceScriptEtag: sha256Etag(appearanceScript),
|
||||
html: rendered,
|
||||
htmlEtag: sha256Etag(rendered),
|
||||
script,
|
||||
@@ -83,12 +91,27 @@ describe("Dashboard host asset loading", () => {
|
||||
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects an empty page or script as an unavailable shell build", () => {
|
||||
for (const [html, script] of [
|
||||
["", "export {};"],
|
||||
["<html></html>", " \n"],
|
||||
it("fails when the appearance script is missing even though the page and script were read", async () => {
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||
.mockResolvedValueOnce("export {};\n")
|
||||
.mockRejectedValueOnce(new Error("ENOENT appearance.js"));
|
||||
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
expect(readFileMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("rejects an empty page, script, or appearance script as an unavailable shell build", () => {
|
||||
for (const [html, script, appearance] of [
|
||||
["", "export {};", "(() => {})();"],
|
||||
["<html></html>", " \n", "(() => {})();"],
|
||||
["<html></html>", "export {};", " "],
|
||||
] as const) {
|
||||
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
|
||||
expect(() => createDashboardHostAssets(html, script, appearance)).toThrow(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -99,7 +122,10 @@ describe("Dashboard host asset loading", () => {
|
||||
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
|
||||
]) {
|
||||
readFileMock.mockReset();
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(html)
|
||||
.mockResolvedValueOnce("export {};\n")
|
||||
.mockResolvedValueOnce("(() => {})();\n");
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleRouteHandler,
|
||||
parseModuleManifest,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -13,9 +14,15 @@ 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,
|
||||
RESERVED_SETTINGS_NAMES,
|
||||
RESERVED_TOP_LEVEL_NAMES,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -31,13 +38,27 @@ import {
|
||||
} from "../src/dashboard-host-assets.js";
|
||||
import {
|
||||
DASHBOARD_HOST_PAGE_PATH,
|
||||
DASHBOARD_HOST_APPEARANCE_SCRIPT_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, SETTINGS_SECTIONS } 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 { createInMemoryAccountStore } from "./support/in-memory-account-store.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";
|
||||
|
||||
@@ -54,6 +75,8 @@ function sha256Etag(text: string): string {
|
||||
|
||||
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
||||
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
||||
const APPEARANCE_SCRIPT = "(() => { /* pre-paint appearance */ })();\n";
|
||||
const APPEARANCE_SCRIPT_ETAG = sha256Etag(APPEARANCE_SCRIPT);
|
||||
|
||||
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
@@ -78,9 +101,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,11 +154,16 @@ 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(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
|
||||
dashboardHostAssets:
|
||||
options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -193,6 +252,182 @@ 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, APPEARANCE_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_TOP_LEVEL_NAMES].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_TOP_LEVEL_NAMES, page).toContain(page);
|
||||
}
|
||||
});
|
||||
|
||||
it("include exactly the Settings sections the shell interprets, so /settings/<module-id> never collides (ADR 0061)", () => {
|
||||
expect([...SETTINGS_SECTIONS].sort()).toEqual([...RESERVED_SETTINGS_NAMES]);
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
expect(isReservedModuleId(section), section).toBe(true);
|
||||
}
|
||||
expect(RESERVED_MODULE_IDS).toEqual(
|
||||
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -231,8 +466,45 @@ describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /assets/ekklesia-dashboard/appearance.js (the pre-paint appearance script)", () => {
|
||||
it("serves the exact pre-paint script as JavaScript with revalidation and its own strong ETag", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(APPEARANCE_SCRIPT);
|
||||
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(response.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG);
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
// Code only: nothing about any viewer, and nothing that varies by request.
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
expect(response.headers.get("vary")).toBeNull();
|
||||
});
|
||||
|
||||
it("answers a matching If-None-Match with a 304, and does not accept the app script's validator", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const notModified = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, {
|
||||
headers: { "If-None-Match": APPEARANCE_SCRIPT_ETAG },
|
||||
});
|
||||
expect(notModified.status).toBe(304);
|
||||
expect(notModified.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG);
|
||||
|
||||
const other = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, {
|
||||
headers: { "If-None-Match": SCRIPT_ETAG },
|
||||
});
|
||||
expect(other.status).toBe(200);
|
||||
});
|
||||
|
||||
it("lives beside the app script, in the Dashboard's own asset namespace", () => {
|
||||
expect(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH).toBe("/assets/ekklesia-dashboard/appearance.js");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard host boundaries", () => {
|
||||
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||
it("serves nothing but its exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
for (const path of [
|
||||
@@ -246,6 +518,10 @@ describe("Dashboard host boundaries", () => {
|
||||
"/assets/ekklesia-dashboard/index.html",
|
||||
"/assets/",
|
||||
"/assets/ekklesia-dashboard/APP.JS",
|
||||
"/assets/ekklesia-dashboard/appearance.js.map",
|
||||
"/assets/ekklesia-dashboard/appearance.js/",
|
||||
"/assets/ekklesia-dashboard/APPEARANCE.JS",
|
||||
"/appearance.js",
|
||||
"/dashboard",
|
||||
"/login",
|
||||
]) {
|
||||
@@ -334,12 +610,21 @@ describe("Dashboard host boundaries", () => {
|
||||
});
|
||||
|
||||
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
||||
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
|
||||
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT);
|
||||
|
||||
for (const assets of [
|
||||
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
||||
{ ...consistent, scriptEtag: PAGE_ETAG },
|
||||
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
|
||||
{ ...consistent, appearanceScriptEtag: SCRIPT_ETAG },
|
||||
{ ...consistent, appearanceScript: `${APPEARANCE_SCRIPT}// tampered` },
|
||||
{
|
||||
appearanceScript: APPEARANCE_SCRIPT,
|
||||
appearanceScriptEtag: APPEARANCE_SCRIPT_ETAG,
|
||||
html: "",
|
||||
htmlEtag: sha256Etag(""),
|
||||
script: SCRIPT,
|
||||
scriptEtag: SCRIPT_ETAG,
|
||||
},
|
||||
]) {
|
||||
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -140,6 +141,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
parseEkklesiaUserId,
|
||||
parseModuleManifest,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleConfiguration,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
DASHBOARD_SETTINGS_PATH,
|
||||
type DashboardSettingsResponse,
|
||||
} from "../src/dashboard-settings-routes.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.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";
|
||||
|
||||
/**
|
||||
* `GET /api/v1/dashboard/settings` (ADR 0061): which Settings destinations the viewer may see,
|
||||
* evaluated by the server through Core's one authorization evaluator. Presentation hints, never
|
||||
* authority: every Settings data route keeps its own enforcement.
|
||||
*/
|
||||
|
||||
const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444");
|
||||
const viewer = createAuthenticatedPrincipal(viewerId);
|
||||
|
||||
function discovered(
|
||||
id: string,
|
||||
options: { readonly name?: string; readonly settings: boolean },
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
configuration: options.settings
|
||||
? [
|
||||
{
|
||||
default: "Hello",
|
||||
description: "A greeting.",
|
||||
key: "greeting",
|
||||
maxLength: 40,
|
||||
type: "string",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 8,
|
||||
name: options.name ?? `${id} module`,
|
||||
permissions: [],
|
||||
version: "0.1.0",
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: {
|
||||
server: `/modules/${id}/dist/server/index.js`,
|
||||
ui: `/modules/${id}/dist/ui/index.js`,
|
||||
},
|
||||
manifest,
|
||||
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
||||
packageName: `@test/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
interface Options {
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly owner?: boolean;
|
||||
readonly grants?: readonly string[];
|
||||
readonly modules?: readonly DiscoveredModule[];
|
||||
readonly configuration?: ModuleLifecycleConfiguration;
|
||||
readonly ownerLookupFails?: boolean;
|
||||
}
|
||||
|
||||
function build(options: Options = {}) {
|
||||
const modules = options.modules ?? [
|
||||
discovered("signage", { name: "Signage", settings: true }),
|
||||
discovered("calendar", { name: "Calendar", settings: true }),
|
||||
discovered("retired", { name: "Archive", settings: true }),
|
||||
discovered("plain", { name: "Plain", settings: false }),
|
||||
];
|
||||
const isPlatformOwner = vi.fn(async () => {
|
||||
if (options.ownerLookupFails) {
|
||||
throw new Error("database down");
|
||||
}
|
||||
return options.owner === true;
|
||||
});
|
||||
const grants = new Set(options.grants ?? []);
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async ({ permission }) => grants.has(String(permission)) },
|
||||
platformOwners: { isPlatformOwner },
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? viewer),
|
||||
};
|
||||
const store = {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn(),
|
||||
isPlatformOwner,
|
||||
listGrantsForUser: vi.fn(),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
} as unknown as AuthorizationStore;
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(
|
||||
modules,
|
||||
options.configuration ?? { modules: [{ enabled: false, moduleId: "retired" }] },
|
||||
),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
runtime,
|
||||
trustedOrigins: ["https://dashboard.example.test"],
|
||||
});
|
||||
return { app, isPlatformOwner };
|
||||
}
|
||||
|
||||
async function settings(options: Options = {}) {
|
||||
const { app } = build(options);
|
||||
const response = await app.request(DASHBOARD_SETTINGS_PATH);
|
||||
const body =
|
||||
response.status === 200 ? ((await response.json()) as DashboardSettingsResponse) : undefined;
|
||||
return { body, response };
|
||||
}
|
||||
|
||||
describe("GET /api/v1/dashboard/settings", () => {
|
||||
it("shows a signed-in person with no administrative permission nothing at all", async () => {
|
||||
const { body, response } = await settings();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(body).toEqual({ modules: [], sections: [] });
|
||||
});
|
||||
|
||||
it("refuses an anonymous viewer, like the navigation it sits beside", async () => {
|
||||
const { response } = await settings({ principal: ANONYMOUS_PRINCIPAL });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("shows the platform owner every section, in order, and every module with settings", async () => {
|
||||
const { body } = await settings({ owner: true });
|
||||
|
||||
expect(body).toEqual({
|
||||
modules: [
|
||||
{ available: false, id: "retired", name: "Archive" },
|
||||
{ available: true, id: "calendar", name: "Calendar" },
|
||||
{ available: true, id: "signage", name: "Signage" },
|
||||
],
|
||||
sections: ["general", "branding", "displays", "discover", "system"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Displays exactly to whoever may manage devices", async () => {
|
||||
const { body } = await settings({ grants: [String(CORE_DEVICES_MANAGE)] });
|
||||
|
||||
expect(body).toEqual({ modules: [], sections: ["displays"] });
|
||||
});
|
||||
|
||||
it("shows Discover — module lifecycle — exactly to whoever may manage which modules run", async () => {
|
||||
const { body } = await settings({ grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)] });
|
||||
|
||||
expect(body).toEqual({ modules: [], sections: ["discover"] });
|
||||
});
|
||||
|
||||
it("lists modules' settings exactly to whoever may configure modules, flagging disabled ones", async () => {
|
||||
const { body } = await settings({ grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)] });
|
||||
|
||||
// A module that declares no configuration has no settings page; a disabled module is listed so
|
||||
// its address can say it is unavailable, but marked so the Dashboard does not navigate to it.
|
||||
expect(body).toEqual({
|
||||
modules: [
|
||||
{ available: false, id: "retired", name: "Archive" },
|
||||
{ available: true, id: "calendar", name: "Calendar" },
|
||||
{ available: true, id: "signage", name: "Signage" },
|
||||
],
|
||||
sections: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps General, Branding, and System to the platform owner until each has a permission of its own", async () => {
|
||||
const { body } = await settings({
|
||||
grants: [
|
||||
String(CORE_DEVICES_MANAGE),
|
||||
String(CORE_MODULES_LIFECYCLE_MANAGE),
|
||||
String(CORE_MODULES_CONFIGURATION_MANAGE),
|
||||
],
|
||||
});
|
||||
|
||||
expect(body?.sections).toEqual(["displays", "discover"]);
|
||||
});
|
||||
|
||||
it("is a 503, never an empty answer, when authorization cannot be evaluated", async () => {
|
||||
const { response } = await settings({ ownerLookupFails: true });
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||
});
|
||||
|
||||
it("is a hint, not authority: the data behind a section stays refused without its permission", async () => {
|
||||
const { app } = build({ grants: [] });
|
||||
|
||||
for (const path of [
|
||||
"/api/v1/module-lifecycle",
|
||||
"/api/v1/module-configuration/signage",
|
||||
"/api/v1/grants",
|
||||
]) {
|
||||
const response = await app.request(path);
|
||||
expect(response.status, path).toBe(403);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
HEALTH_READY_PATH,
|
||||
READINESS_DATABASE_TIMEOUT_MS,
|
||||
} from "../src/health-routes.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
@@ -91,6 +92,7 @@ function buildApp(
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health,
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
|
||||
loadDashboardHostAssetsMock.mockImplementation(async () =>
|
||||
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
|
||||
actual.createDashboardHostAssets("<!doctype html>", "export {};\n", "(() => {})();\n"),
|
||||
);
|
||||
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import { createApiApp } from "../src/app.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -190,6 +191,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: authorizationStore,
|
||||
},
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -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);
|
||||
@@ -226,6 +249,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -256,6 +280,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
"db-facade:0000_initial",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
@@ -760,7 +785,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();
|
||||
|
||||
@@ -37,6 +37,7 @@ import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -199,6 +200,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: fakeAuthorizationStore(),
|
||||
},
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
mergedVaryHeader,
|
||||
} from "../src/module-http-routes.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.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";
|
||||
@@ -125,6 +126,7 @@ function buildModuleApp(options: {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -32,6 +32,7 @@ import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -172,6 +173,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: authorizationStore,
|
||||
},
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -33,8 +33,10 @@ import {
|
||||
createModuleUiContext,
|
||||
type EkklesiaHostConfig,
|
||||
moduleUiSourceUrl,
|
||||
saveHostAccountPreferences,
|
||||
} from "@ekklesia/shell";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -216,6 +218,8 @@ interface HarnessOptions {
|
||||
* single module proves very little. */
|
||||
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
|
||||
readonly platformOwner?: boolean;
|
||||
/** People with an account, for the account area's routes (ADR 0062). */
|
||||
readonly people?: Parameters<typeof createInMemoryAccountStore>[0];
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
@@ -289,6 +293,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
account: createInMemoryAccountStore(options.people ?? []),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -302,7 +307,10 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
if (url.pathname.startsWith("/api/v1/modules/")) {
|
||||
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`);
|
||||
}
|
||||
return app.request(url.href, init);
|
||||
// A browser names the Dashboard's origin on every cross-origin request; the shell never sets it.
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Origin", "https://dashboard.example.test");
|
||||
return app.request(url.href, { ...init, headers });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
return {
|
||||
@@ -738,6 +746,39 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
}
|
||||
});
|
||||
|
||||
it("loads the signed-in person's own account, and a saved preference is what the next load sees (ADR 0062)", async () => {
|
||||
const harness = await startHarness({
|
||||
people: [{ email: "[email protected]", name: "Viewer Person", userId: viewerId }],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
try {
|
||||
const first = await bootstrapHost(harness.config);
|
||||
expect(first.account).toEqual({
|
||||
email: "[email protected]",
|
||||
name: "Viewer Person",
|
||||
preferences: { appearance: "system", motion: "system" },
|
||||
});
|
||||
|
||||
expect(await saveHostAccountPreferences(harness.config, { appearance: "dark" })).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "system",
|
||||
});
|
||||
expect(await saveHostAccountPreferences(harness.config, { motion: "reduce" })).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "reduce",
|
||||
});
|
||||
|
||||
// Another device signing in reads the account, not this one's browser.
|
||||
const next = await bootstrapHost(harness.config);
|
||||
expect(next.account !== "unavailable" && next.account.preferences).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "reduce",
|
||||
});
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
|
||||
const harness = await startHarness();
|
||||
try {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -209,6 +210,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/mod
|
||||
import { Window } from "happy-dom";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -254,6 +255,7 @@ async function install(
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -9,5 +9,6 @@ export function testDashboardHostAssets(): DashboardHostAssets {
|
||||
return createDashboardHostAssets(
|
||||
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||
"export const dashboardHost = true;\n",
|
||||
"(() => {})();\n",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||
import {
|
||||
type AccountPreferences,
|
||||
type AccountPreferencesUpdate,
|
||||
type AccountStore,
|
||||
AccountStoreError,
|
||||
type AccountSummary,
|
||||
DEFAULT_ACCOUNT_PREFERENCES,
|
||||
parseAccountPreferencesUpdate,
|
||||
} from "@ekklesia/core/account";
|
||||
|
||||
export interface InMemoryAccountPerson {
|
||||
readonly userId: string;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly preferences?: Partial<AccountPreferences>;
|
||||
}
|
||||
|
||||
export interface InMemoryAccountStore extends AccountStore {
|
||||
/** Every user id a read or write was asked for, in order, so a route test can prove which account
|
||||
* the route touched — the session's, and never one named by the request. */
|
||||
readonly requestedUserIds: readonly string[];
|
||||
/** Makes the next operations fail the way an unreachable database would. */
|
||||
failWith(code: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED" | undefined): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory `AccountStore` for tests that exercise **routing and authorization**, not
|
||||
* persistence. It reproduces the store's contract — strict update parsing, partial updates, an
|
||||
* unknown person is not found — and nothing a database owns; persistence, the `CHECK`
|
||||
* constraints, isolation across connections, and the cascade are proven against real PostgreSQL in
|
||||
* `core/tests/account-store.integration.ts`.
|
||||
*/
|
||||
export function createInMemoryAccountStore(
|
||||
people: readonly InMemoryAccountPerson[] = [],
|
||||
): InMemoryAccountStore {
|
||||
const records = new Map<string, { name: string; email: string; preferences: AccountPreferences }>(
|
||||
people.map((person) => [
|
||||
person.userId,
|
||||
{
|
||||
email: person.email,
|
||||
name: person.name,
|
||||
preferences: { ...DEFAULT_ACCOUNT_PREFERENCES, ...person.preferences },
|
||||
},
|
||||
]),
|
||||
);
|
||||
const requestedUserIds: string[] = [];
|
||||
let failure: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED" | undefined;
|
||||
|
||||
return {
|
||||
failWith(code) {
|
||||
failure = code;
|
||||
},
|
||||
async readAccount(userId: EkklesiaUserId): Promise<AccountSummary | undefined> {
|
||||
requestedUserIds.push(userId);
|
||||
if (failure) {
|
||||
throw new AccountStoreError("ACCOUNT_READ_FAILED");
|
||||
}
|
||||
const record = records.get(userId);
|
||||
return record
|
||||
? { email: record.email, name: record.name, preferences: { ...record.preferences } }
|
||||
: undefined;
|
||||
},
|
||||
requestedUserIds,
|
||||
async updatePreferences(
|
||||
userId: EkklesiaUserId,
|
||||
update: AccountPreferencesUpdate,
|
||||
): Promise<AccountPreferences> {
|
||||
requestedUserIds.push(userId);
|
||||
const parsed = parseAccountPreferencesUpdate(update);
|
||||
if (!parsed) {
|
||||
throw new AccountStoreError("ACCOUNT_PREFERENCES_INVALID");
|
||||
}
|
||||
if (failure) {
|
||||
throw new AccountStoreError("ACCOUNT_WRITE_FAILED");
|
||||
}
|
||||
const record = records.get(userId);
|
||||
if (!record) {
|
||||
throw new AccountStoreError("ACCOUNT_NOT_FOUND");
|
||||
}
|
||||
record.preferences = { ...record.preferences, ...parsed };
|
||||
return { ...record.preferences };
|
||||
},
|
||||
};
|
||||
}
|
||||
+4
-1
@@ -19,7 +19,10 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended"
|
||||
"preset": "recommended",
|
||||
"suspicious": {
|
||||
"noAlert": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
"./src/modules/lifecycle-schema.ts",
|
||||
"./src/modules/configuration-schema.ts",
|
||||
"./src/devices/schema.ts",
|
||||
"./src/account/schema.ts",
|
||||
],
|
||||
out: "./drizzle",
|
||||
migrations: {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "ekklesia_core_user_preference" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"appearance" text,
|
||||
"motion" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ekklesia_core_user_preference_appearance_check" CHECK ("ekklesia_core_user_preference"."appearance" is null or "ekklesia_core_user_preference"."appearance" in ('system', 'light', 'dark')),
|
||||
CONSTRAINT "ekklesia_core_user_preference_motion_check" CHECK ("ekklesia_core_user_preference"."motion" is null or "ekklesia_core_user_preference"."motion" in ('system', 'reduce', 'allow'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_user_preference" ADD CONSTRAINT "ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,12 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0008_user_preferences",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1787543773058,
|
||||
"tag": "0007_verified_bootstrap_owners",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1792036365485,
|
||||
"tag": "0008_user_preferences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./account": {
|
||||
"types": "./dist/account/index.d.ts",
|
||||
"import": "./dist/account/index.js"
|
||||
},
|
||||
"./modules": {
|
||||
"types": "./dist/modules/index.d.ts",
|
||||
"import": "./dist/modules/index.js"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type { Pool } from "pg";
|
||||
import { accountSchema } from "./schema.js";
|
||||
|
||||
export type AccountDrizzleDatabase = NodePgDatabase<typeof accountSchema>;
|
||||
|
||||
/**
|
||||
* A typed Drizzle view over an externally owned pool, exactly like `createDeviceDrizzleView`: it
|
||||
* never constructs, registers an error listener on, or closes a pool.
|
||||
*/
|
||||
export function createAccountDrizzleView(pool: Pool): AccountDrizzleDatabase {
|
||||
return drizzle(pool, { schema: accountSchema });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./database.js";
|
||||
export * from "./preferences.js";
|
||||
export * from "./schema.js";
|
||||
export * from "./store.js";
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* A person's own preferences (DESIGN.md §9.2, ADR 0062): how the Dashboard looks and behaves *for
|
||||
* them*, on every device they sign in on.
|
||||
*
|
||||
* The model is deliberately a closed, typed record rather than a key/value bag or a JSON document.
|
||||
* Every preference has a fixed name, a closed set of values, and a default; a value outside the set
|
||||
* is refused at this boundary and again by the database's `CHECK` constraint. A new preference is a
|
||||
* new field here and a new nullable column in a migration — never a second table, and never a
|
||||
* free-form value a client could fill with anything.
|
||||
*
|
||||
* Core cannot import the design-token package that renders these, so the value sets are declared
|
||||
* here and an `apps/api` test holds them equal to `@ekklesia/design-tokens`' own
|
||||
* (`EKKLESIA_APPEARANCES`, `EKKLESIA_MOTION_PREFERENCES`): one list cannot change without the other.
|
||||
*/
|
||||
|
||||
/** Light, dark, or following the operating system. `system` is the default. */
|
||||
export const ACCOUNT_APPEARANCES = Object.freeze(["system", "light", "dark"] as const);
|
||||
export type AccountAppearance = (typeof ACCOUNT_APPEARANCES)[number];
|
||||
|
||||
/** Following `prefers-reduced-motion`, always reducing, or always allowing the platform's subtle
|
||||
* motion. `system` is the default. */
|
||||
export const ACCOUNT_MOTION_PREFERENCES = Object.freeze(["system", "reduce", "allow"] as const);
|
||||
export type AccountMotionPreference = (typeof ACCOUNT_MOTION_PREFERENCES)[number];
|
||||
|
||||
export interface AccountPreferences {
|
||||
readonly appearance: AccountAppearance;
|
||||
readonly motion: AccountMotionPreference;
|
||||
}
|
||||
|
||||
/** What a person who has never chosen anything gets: follow the system for both. */
|
||||
export const DEFAULT_ACCOUNT_PREFERENCES: AccountPreferences = Object.freeze({
|
||||
appearance: "system",
|
||||
motion: "system",
|
||||
});
|
||||
|
||||
/** A change to some of a person's preferences. Fields left out keep their stored value, so
|
||||
* changing motion on a phone never overwrites an appearance chosen a moment earlier on a laptop. */
|
||||
export type AccountPreferencesUpdate = Partial<AccountPreferences>;
|
||||
|
||||
const PREFERENCE_VALUES: Readonly<Record<keyof AccountPreferences, readonly string[]>> =
|
||||
Object.freeze({
|
||||
appearance: ACCOUNT_APPEARANCES,
|
||||
motion: ACCOUNT_MOTION_PREFERENCES,
|
||||
});
|
||||
|
||||
/** The preference names, in a stable order. */
|
||||
export const ACCOUNT_PREFERENCE_NAMES = Object.freeze(
|
||||
Object.keys(PREFERENCE_VALUES).sort() as (keyof AccountPreferences)[],
|
||||
);
|
||||
|
||||
export function isAccountAppearance(value: unknown): value is AccountAppearance {
|
||||
return typeof value === "string" && ACCOUNT_APPEARANCES.includes(value as AccountAppearance);
|
||||
}
|
||||
|
||||
export function isAccountMotionPreference(value: unknown): value is AccountMotionPreference {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
ACCOUNT_MOTION_PREFERENCES.includes(value as AccountMotionPreference)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an untrusted update strictly: a plain object naming at least one known preference, each
|
||||
* with one of its allowed values, and nothing else. Returns `undefined` for anything else — an
|
||||
* unknown key, a wrong value, an empty object, an array, `null` — so a malformed request is refused
|
||||
* whole rather than partly applied, and no unexpected key can ever reach storage.
|
||||
*/
|
||||
export function parseAccountPreferencesUpdate(
|
||||
value: unknown,
|
||||
): AccountPreferencesUpdate | undefined {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
if (Object.getPrototypeOf(value) !== Object.prototype) {
|
||||
return undefined;
|
||||
}
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const update: { appearance?: AccountAppearance; motion?: AccountMotionPreference } = {};
|
||||
for (const [key, entry] of entries) {
|
||||
if (key === "appearance" && isAccountAppearance(entry)) {
|
||||
update.appearance = entry;
|
||||
} else if (key === "motion" && isAccountMotionPreference(entry)) {
|
||||
update.motion = entry;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return Object.freeze(update);
|
||||
}
|
||||
|
||||
/** Resolves stored columns, where `null` means "never chosen", to the preferences in effect. A
|
||||
* stored value the database constraint should have made impossible is treated as never chosen. */
|
||||
export function resolveAccountPreferences(stored: {
|
||||
readonly appearance: string | null;
|
||||
readonly motion: string | null;
|
||||
}): AccountPreferences {
|
||||
return Object.freeze({
|
||||
appearance: isAccountAppearance(stored.appearance)
|
||||
? stored.appearance
|
||||
: DEFAULT_ACCOUNT_PREFERENCES.appearance,
|
||||
motion: isAccountMotionPreference(stored.motion)
|
||||
? stored.motion
|
||||
: DEFAULT_ACCOUNT_PREFERENCES.motion,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { check, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||
|
||||
/**
|
||||
* Core-owned storage for a person's own preferences (ADR 0062).
|
||||
*
|
||||
* **One row per person, one typed column per preference.** There is deliberately no key/value
|
||||
* table and no JSON column: each preference has a fixed name and a closed set of values, enforced
|
||||
* here by a `CHECK` constraint beneath the application-layer parser
|
||||
* (`parseAccountPreferencesUpdate`), so nothing but a known value can be stored. A new preference is
|
||||
* a new nullable column in a new migration.
|
||||
*
|
||||
* **`null` means "never chosen"**, and is resolved to the platform default when read
|
||||
* (`resolveAccountPreferences`) rather than written as a column default. That keeps "this person
|
||||
* chose System" distinguishable from "this person never chose", and lets a later preference column
|
||||
* arrive without backfilling a value nobody chose — the same reasoning
|
||||
* `ekklesia_core_module_state` follows for its absent rows (ADR 0028). A person with no row at all
|
||||
* has chosen nothing.
|
||||
*
|
||||
* The row belongs to its person: `user_id` is the primary key and cascades on delete, so deleting
|
||||
* an account removes its preferences with it, and no row can exist for a person who does not.
|
||||
*
|
||||
* Personal, not church-wide. Nothing here is branding, a church theme, or an administrator's
|
||||
* decision for anyone else; those are Settings' (ADR 0061).
|
||||
*/
|
||||
export const ekklesiaCoreUserPreference = pgTable(
|
||||
"ekklesia_core_user_preference",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.primaryKey()
|
||||
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
|
||||
appearance: text("appearance"),
|
||||
motion: text("motion"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
check(
|
||||
"ekklesia_core_user_preference_appearance_check",
|
||||
sql`${table.appearance} is null or ${table.appearance} in ('system', 'light', 'dark')`,
|
||||
),
|
||||
check(
|
||||
"ekklesia_core_user_preference_motion_check",
|
||||
sql`${table.motion} is null or ${table.motion} in ('system', 'reduce', 'allow')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/** Explicit map passed to Drizzle, mirroring `dashboardSchema` and `moduleLifecycleSchema`. The
|
||||
* user table is included because the account summary reads a person's own name and email from it;
|
||||
* nothing in this domain writes to it. */
|
||||
export const accountSchema = {
|
||||
user: ekklesiaCoreUser,
|
||||
userPreference: ekklesiaCoreUserPreference,
|
||||
} as const;
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||
import type { AccountDrizzleDatabase } from "./database.js";
|
||||
import {
|
||||
type AccountPreferences,
|
||||
type AccountPreferencesUpdate,
|
||||
parseAccountPreferencesUpdate,
|
||||
resolveAccountPreferences,
|
||||
} from "./preferences.js";
|
||||
import { ekklesiaCoreUserPreference } from "./schema.js";
|
||||
|
||||
export type AccountStoreErrorCode =
|
||||
| "ACCOUNT_NOT_FOUND"
|
||||
| "ACCOUNT_PREFERENCES_INVALID"
|
||||
| "ACCOUNT_READ_FAILED"
|
||||
| "ACCOUNT_WRITE_FAILED";
|
||||
|
||||
const accountStoreMessages: Readonly<Record<AccountStoreErrorCode, string>> = Object.freeze({
|
||||
ACCOUNT_NOT_FOUND: "The account was not found.",
|
||||
ACCOUNT_PREFERENCES_INVALID: "The preferences are not valid.",
|
||||
ACCOUNT_READ_FAILED: "The account could not be read.",
|
||||
ACCOUNT_WRITE_FAILED: "The preferences could not be saved.",
|
||||
});
|
||||
|
||||
/** Never carries the underlying database error, SQL text, host, or stack — matching
|
||||
* `DashboardStoreError` exactly. */
|
||||
export class AccountStoreError extends Error {
|
||||
readonly code: AccountStoreErrorCode;
|
||||
|
||||
constructor(code: AccountStoreErrorCode) {
|
||||
super(accountStoreMessages[code]);
|
||||
this.name = "AccountStoreError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a signed-in person is shown about their own account: the name and email they signed up
|
||||
* with, and their preferences. Deliberately nothing else — not the image URL, not verification
|
||||
* state, not timestamps — so the one place the browser learns about an account carries exactly
|
||||
* what the account area displays (ADR 0062).
|
||||
*/
|
||||
export interface AccountSummary {
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly preferences: AccountPreferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* A person's own account, and only their own: every method takes the one user id whose account it
|
||||
* reads or writes, and the HTTP surface supplies it from the authenticated session, never from the
|
||||
* request. There is no method that lists accounts or reads someone else's by any other key.
|
||||
*/
|
||||
export interface AccountStore {
|
||||
/** The person's summary, or `undefined` when no such person exists. A person who never chose a
|
||||
* preference gets the defaults. */
|
||||
readAccount(userId: EkklesiaUserId): Promise<AccountSummary | undefined>;
|
||||
/** Changes the named preferences and keeps the rest, returning everything now in effect. */
|
||||
updatePreferences(
|
||||
userId: EkklesiaUserId,
|
||||
update: AccountPreferencesUpdate,
|
||||
): Promise<AccountPreferences>;
|
||||
}
|
||||
|
||||
function rethrowAsStoreError(error: unknown, code: AccountStoreErrorCode): never {
|
||||
if (error instanceof AccountStoreError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AccountStoreError(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's persistence for a person's own account summary and preferences.
|
||||
*
|
||||
* Validation lives here as well as at the HTTP boundary, so the invariants hold for any caller: an
|
||||
* update is re-read through the same strict parser, and a preference row can only be written for a
|
||||
* person who exists.
|
||||
*/
|
||||
export function createAccountStore(db: AccountDrizzleDatabase): AccountStore {
|
||||
return Object.freeze({
|
||||
async readAccount(userId: EkklesiaUserId): Promise<AccountSummary | undefined> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
appearance: ekklesiaCoreUserPreference.appearance,
|
||||
email: ekklesiaCoreUser.email,
|
||||
motion: ekklesiaCoreUserPreference.motion,
|
||||
name: ekklesiaCoreUser.name,
|
||||
})
|
||||
.from(ekklesiaCoreUser)
|
||||
.leftJoin(
|
||||
ekklesiaCoreUserPreference,
|
||||
eq(ekklesiaCoreUserPreference.userId, ekklesiaCoreUser.id),
|
||||
)
|
||||
.where(eq(ekklesiaCoreUser.id, userId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
preferences: resolveAccountPreferences(row),
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error, "ACCOUNT_READ_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
async updatePreferences(
|
||||
userId: EkklesiaUserId,
|
||||
update: AccountPreferencesUpdate,
|
||||
): Promise<AccountPreferences> {
|
||||
const parsed = parseAccountPreferencesUpdate(update);
|
||||
if (parsed === undefined) {
|
||||
throw new AccountStoreError("ACCOUNT_PREFERENCES_INVALID");
|
||||
}
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
// Checked inside the transaction rather than left to the foreign key, so a person who
|
||||
// does not exist is a clear "not found" instead of a constraint failure.
|
||||
const people = await tx
|
||||
.select({ id: ekklesiaCoreUser.id })
|
||||
.from(ekklesiaCoreUser)
|
||||
.where(eq(ekklesiaCoreUser.id, userId))
|
||||
.limit(1);
|
||||
if (people.length === 0) {
|
||||
throw new AccountStoreError("ACCOUNT_NOT_FOUND");
|
||||
}
|
||||
const rows = await tx
|
||||
.insert(ekklesiaCoreUserPreference)
|
||||
.values({ userId, ...parsed })
|
||||
.onConflictDoUpdate({
|
||||
set: { ...parsed, updatedAt: sql`now()` },
|
||||
target: ekklesiaCoreUserPreference.userId,
|
||||
})
|
||||
.returning({
|
||||
appearance: ekklesiaCoreUserPreference.appearance,
|
||||
motion: ekklesiaCoreUserPreference.motion,
|
||||
});
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
throw new AccountStoreError("ACCOUNT_WRITE_FAILED");
|
||||
}
|
||||
return resolveAccountPreferences(row);
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error, "ACCOUNT_WRITE_FAILED");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -169,6 +169,20 @@ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId
|
||||
*/
|
||||
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage");
|
||||
|
||||
/**
|
||||
* Authenticated: every signed-in person may read their own name and email and read and change their
|
||||
* own preferences (ADR 0062). Choosing dark mode is not church administration, so it needs no
|
||||
* grant, and a Dashboard where it did would be unusable for its ordinary users. It is a declared
|
||||
* permission rather than a hand-written "is signed in" check, as `core.modules.ui.read` is
|
||||
* (ADR 0026), so the account area is authorized by the same evaluator as everything else.
|
||||
*
|
||||
* **It is self-only by construction.** The routes it guards take the account from the
|
||||
* authenticated session and accept no user id at all, so holding it — whether through the baseline
|
||||
* or a grant — confers no ability to read or change anyone else's account. A device never satisfies
|
||||
* the `authenticated` baseline: a screen in the foyer has no account to read.
|
||||
*/
|
||||
export const CORE_ACCOUNT_SELF_MANAGE: PermissionId = parsePermissionId("core.account.self.manage");
|
||||
|
||||
/**
|
||||
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
||||
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
||||
@@ -207,6 +221,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
|
||||
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||
id: CORE_DEVICES_MANAGE,
|
||||
}),
|
||||
Object.freeze({
|
||||
baseline: "authenticated" as const,
|
||||
description: "Read your own account and read and change your own preferences.",
|
||||
id: CORE_ACCOUNT_SELF_MANAGE,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 id is a stable URL identifier: its page is `/<module-id>` (ADR 0057) and its
|
||||
// settings `/settings/<module-id>` (ADR 0061), beside the platform's own pages in both.
|
||||
issues.push({
|
||||
code: "MODULE_ID_RESERVED",
|
||||
message: `Module ID ${manifest.id} is reserved for the platform's 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";
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Names the platform owns, which therefore can never be a module id (ADR 0057, ADR 0061).
|
||||
*
|
||||
* A module id is a **stable URL identifier**. Every module with a Dashboard UI is addressed at
|
||||
* `/<module-id>`, and every module with settings at `/settings/<module-id>`, beside the platform's
|
||||
* own pages in the same two namespaces. A module called `settings` would collide with the
|
||||
* Dashboard's Settings, and one called `discover` with Settings → Discover. This is the complete set
|
||||
* the platform serves today, not a guess at what it might want later.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Top-level namespaces (`/<name>`):
|
||||
*
|
||||
* - `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/…`);
|
||||
* - `display` — the display host's pages and service worker (`/display/…`);
|
||||
* - `settings` and `account` — the Dashboard's own Settings and account areas.
|
||||
*/
|
||||
export const RESERVED_TOP_LEVEL_NAMES = Object.freeze([
|
||||
"account",
|
||||
"api",
|
||||
"assets",
|
||||
"display",
|
||||
"settings",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* The platform's own Settings sections (`/settings/<name>`): General, Branding, Displays, Discover
|
||||
* (finding, installing, and managing modules), and System. Every other `/settings/<name>` is a
|
||||
* module's settings. Discover's own pages live beneath it (`/settings/discover/…`), so they cannot
|
||||
* collide with a module and need no reservation of their own.
|
||||
*/
|
||||
export const RESERVED_SETTINGS_NAMES = Object.freeze([
|
||||
"branding",
|
||||
"discover",
|
||||
"displays",
|
||||
"general",
|
||||
"system",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* Every reserved name, sorted. `apps/api` holds this list to the routes it actually registers and to
|
||||
* the pages and Settings sections the shell interprets, so a new platform route or Settings section
|
||||
* 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(
|
||||
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(),
|
||||
);
|
||||
|
||||
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);
|
||||
|
||||
export function isReservedModuleId(moduleId: string): boolean {
|
||||
return reserved.has(moduleId);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getTableConfig } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACCOUNT_APPEARANCES,
|
||||
ACCOUNT_MOTION_PREFERENCES,
|
||||
ACCOUNT_PREFERENCE_NAMES,
|
||||
AccountStoreError,
|
||||
createAccountStore,
|
||||
DEFAULT_ACCOUNT_PREFERENCES,
|
||||
ekklesiaCoreUserPreference,
|
||||
parseAccountPreferencesUpdate,
|
||||
resolveAccountPreferences,
|
||||
} from "../src/account/index.js";
|
||||
|
||||
const migrationsDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
|
||||
describe("account preferences model", () => {
|
||||
it("defaults both preferences to following the system", () => {
|
||||
expect(DEFAULT_ACCOUNT_PREFERENCES).toEqual({ appearance: "system", motion: "system" });
|
||||
expect(Object.isFrozen(DEFAULT_ACCOUNT_PREFERENCES)).toBe(true);
|
||||
});
|
||||
|
||||
it("declares a closed, ordered set of names and values", () => {
|
||||
expect(ACCOUNT_PREFERENCE_NAMES).toEqual(["appearance", "motion"]);
|
||||
expect(ACCOUNT_APPEARANCES).toEqual(["system", "light", "dark"]);
|
||||
expect(ACCOUNT_MOTION_PREFERENCES).toEqual(["system", "reduce", "allow"]);
|
||||
});
|
||||
|
||||
it.each(
|
||||
ACCOUNT_APPEARANCES.map((appearance) => [appearance] as const),
|
||||
)("accepts appearance %s on its own", (appearance) => {
|
||||
expect(parseAccountPreferencesUpdate({ appearance })).toEqual({ appearance });
|
||||
});
|
||||
|
||||
it.each(
|
||||
ACCOUNT_MOTION_PREFERENCES.map((motion) => [motion] as const),
|
||||
)("accepts motion %s on its own", (motion) => {
|
||||
expect(parseAccountPreferencesUpdate({ motion })).toEqual({ motion });
|
||||
});
|
||||
|
||||
it("accepts both preferences together", () => {
|
||||
expect(parseAccountPreferencesUpdate({ appearance: "dark", motion: "reduce" })).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "reduce",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty object", {}],
|
||||
["null", null],
|
||||
["an array", [["appearance", "dark"]]],
|
||||
["a string", "dark"],
|
||||
["an unknown appearance", { appearance: "sepia" }],
|
||||
["an unknown motion", { motion: "none" }],
|
||||
["a differently cased value", { appearance: "Dark" }],
|
||||
["a non-string value", { appearance: 1 }],
|
||||
["a null value", { motion: null }],
|
||||
["an unknown key beside a valid one", { appearance: "dark", radius: "large" }],
|
||||
["an unknown key alone", { theme: "dark" }],
|
||||
["a user id", { appearance: "dark", userId: "someone-else" }],
|
||||
["a prototype key", JSON.parse('{"__proto__": {"appearance": "dark"}}')],
|
||||
[
|
||||
"a class instance",
|
||||
new (class Preferences {
|
||||
appearance = "dark";
|
||||
})(),
|
||||
],
|
||||
])("refuses %s whole", (_label, value) => {
|
||||
expect(parseAccountPreferencesUpdate(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves never-chosen columns to the defaults, and keeps a chosen value", () => {
|
||||
expect(resolveAccountPreferences({ appearance: null, motion: null })).toEqual(
|
||||
DEFAULT_ACCOUNT_PREFERENCES,
|
||||
);
|
||||
expect(resolveAccountPreferences({ appearance: "dark", motion: null })).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "system",
|
||||
});
|
||||
// A value the constraint should have made impossible is never passed through.
|
||||
expect(resolveAccountPreferences({ appearance: "neon", motion: "allow" })).toEqual({
|
||||
appearance: "system",
|
||||
motion: "allow",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("account preference storage", () => {
|
||||
it("is one row per person, keyed and cascaded by user id, with one typed column per preference", () => {
|
||||
const config = getTableConfig(ekklesiaCoreUserPreference);
|
||||
expect(config.name).toBe("ekklesia_core_user_preference");
|
||||
expect(config.columns.map((column) => column.name).sort()).toEqual([
|
||||
"appearance",
|
||||
"created_at",
|
||||
"motion",
|
||||
"updated_at",
|
||||
"user_id",
|
||||
]);
|
||||
const userId = config.columns.find((column) => column.name === "user_id");
|
||||
expect(userId?.primary).toBe(true);
|
||||
// Preference columns are nullable: `null` is "never chosen", resolved to the default on read.
|
||||
for (const name of ACCOUNT_PREFERENCE_NAMES) {
|
||||
expect(config.columns.find((column) => column.name === name)?.notNull).toBe(false);
|
||||
}
|
||||
expect(config.foreignKeys).toHaveLength(1);
|
||||
expect(config.foreignKeys[0]?.onDelete).toBe("cascade");
|
||||
});
|
||||
|
||||
it("constrains each column in the migration to exactly the model's values", async () => {
|
||||
const sql = await readFile(path.join(migrationsDirectory, "0008_user_preferences.sql"), "utf8");
|
||||
const allowed = (column: string): string[] => {
|
||||
const match = new RegExp(`"${column}" in \\(([^)]*)\\)`, "u").exec(sql);
|
||||
return (match?.[1] ?? "").split(",").map((value) => value.trim().replaceAll("'", ""));
|
||||
};
|
||||
expect(allowed("appearance")).toEqual([...ACCOUNT_APPEARANCES]);
|
||||
expect(allowed("motion")).toEqual([...ACCOUNT_MOTION_PREFERENCES]);
|
||||
// No JSON, no key/value pair, nothing a client could fill with anything.
|
||||
expect(sql).not.toMatch(/jsonb?|hstore/iu);
|
||||
});
|
||||
|
||||
it("is a migration that destroys nothing", async () => {
|
||||
const metadata = JSON.parse(
|
||||
await readFile(path.join(migrationsDirectory, "meta", "_ekklesia.json"), "utf8"),
|
||||
) as { entries: { tag: string; destructive: { mode: string } }[] };
|
||||
expect(metadata.entries.find((entry) => entry.tag === "0008_user_preferences")).toEqual({
|
||||
destructive: { mode: "none" },
|
||||
tag: "0008_user_preferences",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("account store validation", () => {
|
||||
it("refuses an invalid update before touching the database", async () => {
|
||||
const untouchable = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("the database must not be reached");
|
||||
},
|
||||
},
|
||||
);
|
||||
const store = createAccountStore(untouchable as never);
|
||||
await expect(
|
||||
store.updatePreferences("user-1" as never, { appearance: "sepia" } as never),
|
||||
).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID", name: "AccountStoreError" });
|
||||
await expect(store.updatePreferences("user-1" as never, {})).rejects.toBeInstanceOf(
|
||||
AccountStoreError,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a database failure without the underlying error", async () => {
|
||||
const failing = {
|
||||
select() {
|
||||
throw new Error("connect ECONNREFUSED postgres://secret@db:5432");
|
||||
},
|
||||
transaction() {
|
||||
throw new Error("connect ECONNREFUSED postgres://secret@db:5432");
|
||||
},
|
||||
};
|
||||
const store = createAccountStore(failing as never);
|
||||
const read = await store.readAccount("user-1" as never).catch((error: unknown) => error);
|
||||
expect(read).toBeInstanceOf(AccountStoreError);
|
||||
expect(read).toMatchObject({ code: "ACCOUNT_READ_FAILED" });
|
||||
expect(String((read as Error).stack)).not.toContain("secret");
|
||||
const write = await store
|
||||
.updatePreferences("user-1" as never, { appearance: "dark" })
|
||||
.catch((error: unknown) => error);
|
||||
expect(write).toMatchObject({ code: "ACCOUNT_WRITE_FAILED" });
|
||||
expect(String((write as Error).message)).not.toContain("ECONNREFUSED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type AccountStore,
|
||||
AccountStoreError,
|
||||
createAccountDrizzleView,
|
||||
createAccountStore,
|
||||
} from "../src/account/index.js";
|
||||
import { createValidatedPostgresPool } from "../src/database/index.js";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "../src/migrations/index.js";
|
||||
|
||||
/**
|
||||
* A person's own account summary and preferences against real PostgreSQL (ADR 0062).
|
||||
*
|
||||
* The unit suite proves the model and parser; this proves what only a database can: that the
|
||||
* migration creates the table with its constraints, that a choice survives a completely new
|
||||
* connection (which is what "follows you to another device" rests on), that one person's change
|
||||
* never touches another's row, that the database itself refuses a value outside the model, and
|
||||
* that deleting a person deletes their preferences.
|
||||
*/
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const coreMigrationsDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
const trackedDatabases = new Set<string>();
|
||||
let databaseCounter = 0;
|
||||
|
||||
class IntegrationHarnessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "IntegrationHarnessError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function harnessError(message: string): IntegrationHarnessError {
|
||||
return new IntegrationHarnessError(message);
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw harnessError("The account integration database is not configured.");
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||
throw new TypeError("Unsupported protocol.");
|
||||
}
|
||||
} catch {
|
||||
throw harnessError("The account integration database configuration is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function quoteDatabaseName(databaseName: string): string {
|
||||
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
||||
throw harnessError("The generated integration database name is invalid.");
|
||||
}
|
||||
return `"${databaseName}"`;
|
||||
}
|
||||
|
||||
function makeDatabaseName(label: string): string {
|
||||
const safeLabel = label
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/gu, "_")
|
||||
.replaceAll(/^_+|_+$/gu, "")
|
||||
.slice(0, 14);
|
||||
databaseCounter += 1;
|
||||
return `ekk_acct_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
function databaseUrl(databaseName: string): string {
|
||||
const parsed = new URL(baseDatabaseUrl());
|
||||
parsed.pathname = `/${databaseName}`;
|
||||
parsed.searchParams.delete("application_name");
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
||||
const client = new Client({ connectionString });
|
||||
client.on("error", () => undefined);
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// The public harness failure stays static when connection cleanup also fails.
|
||||
}
|
||||
throw harnessError("The account integration database connection failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeQuery<Row extends QueryResultRow>(
|
||||
client: Client,
|
||||
text: string,
|
||||
values: unknown[] = [],
|
||||
): Promise<readonly Row[]> {
|
||||
try {
|
||||
const result = await client.query<Row>(text, values);
|
||||
return result.rows;
|
||||
} catch {
|
||||
throw harnessError("An integration database administration operation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeEnd(client: Client): Promise<void> {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
throw harnessError("An integration database connection could not be closed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function dropDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(
|
||||
admin,
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
||||
[databaseName],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
||||
);
|
||||
trackedDatabases.delete(databaseName);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||
const name = makeDatabaseName(label);
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
trackedDatabases.add(name);
|
||||
return { name, url: databaseUrl(name) };
|
||||
}
|
||||
|
||||
async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-acct-no-modules-"));
|
||||
try {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||
"core:0000_identity_core_tables",
|
||||
"core:0001_authorization_core_tables",
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
await rm(emptyModulesDirectory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const databaseName of [...trackedDatabases]) {
|
||||
await dropDatabase(databaseName).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const ada = "user-ada" as EkklesiaUserId;
|
||||
const grace = "user-grace" as EkklesiaUserId;
|
||||
const nobody = "user-nobody" as EkklesiaUserId;
|
||||
|
||||
async function insertPerson(
|
||||
client: Client,
|
||||
id: string,
|
||||
name: string,
|
||||
email: string,
|
||||
): Promise<void> {
|
||||
await safeQuery(
|
||||
client,
|
||||
"insert into ekklesia_core_user (id, name, email, email_verified) values ($1, $2, $3, true)",
|
||||
[id, name, email],
|
||||
);
|
||||
}
|
||||
|
||||
describe.sequential("live Core account store", () => {
|
||||
let database: { name: string; url: string };
|
||||
let admin: Client;
|
||||
let store: AccountStore;
|
||||
let close: () => Promise<void>;
|
||||
|
||||
it("creates the preference table with its constraints", async () => {
|
||||
database = await createDisposableDatabase("fresh");
|
||||
await applyCoreMigrations(database.url);
|
||||
admin = await openAdministrativeClient(database.url);
|
||||
|
||||
const constraints = await safeQuery<{ constraint_name: string; constraint_type: string }>(
|
||||
admin,
|
||||
`select constraint_name, constraint_type from information_schema.table_constraints
|
||||
where table_name = 'ekklesia_core_user_preference' and constraint_type <> 'CHECK'
|
||||
or constraint_name like 'ekklesia_core_user_preference_%_check'
|
||||
order by constraint_name`,
|
||||
);
|
||||
const deleteRules = await safeQuery<{ delete_rule: string }>(
|
||||
admin,
|
||||
`select rc.delete_rule from information_schema.referential_constraints rc
|
||||
join information_schema.table_constraints tc on tc.constraint_name = rc.constraint_name
|
||||
where tc.table_name = 'ekklesia_core_user_preference'`,
|
||||
);
|
||||
|
||||
expect(constraints.map((constraint) => constraint.constraint_name)).toEqual([
|
||||
"ekklesia_core_user_preference_appearance_check",
|
||||
"ekklesia_core_user_preference_motion_check",
|
||||
"ekklesia_core_user_preference_pkey",
|
||||
"ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk",
|
||||
]);
|
||||
expect(deleteRules.map((rule) => rule.delete_rule)).toEqual(["CASCADE"]);
|
||||
|
||||
await insertPerson(admin, ada, "Ada Lovelace", "[email protected]");
|
||||
await insertPerson(admin, grace, "Grace Hopper", "[email protected]");
|
||||
|
||||
const handle = createValidatedPostgresPool(database.url);
|
||||
close = handle.close;
|
||||
store = createAccountStore(createAccountDrizzleView(handle.pool));
|
||||
});
|
||||
|
||||
it("reads a person who never chose anything with their name, email, and the defaults", async () => {
|
||||
expect(await store.readAccount(ada)).toEqual({
|
||||
email: "[email protected]",
|
||||
name: "Ada Lovelace",
|
||||
preferences: { appearance: "system", motion: "system" },
|
||||
});
|
||||
const rows = await safeQuery(admin, "select * from ekklesia_core_user_preference");
|
||||
// Reading never materializes a row nobody chose.
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("answers undefined for a person who does not exist", async () => {
|
||||
expect(await store.readAccount(nobody)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["appearance", "light"],
|
||||
["appearance", "dark"],
|
||||
["appearance", "system"],
|
||||
["motion", "reduce"],
|
||||
["motion", "allow"],
|
||||
["motion", "system"],
|
||||
] as const)("saves %s %s and reads it back", async (name, value) => {
|
||||
const saved = await store.updatePreferences(ada, { [name]: value });
|
||||
expect(saved[name]).toBe(value);
|
||||
expect((await store.readAccount(ada))?.preferences[name]).toBe(value);
|
||||
});
|
||||
|
||||
it("changes only the preference named, keeping the other", async () => {
|
||||
await store.updatePreferences(ada, { appearance: "dark", motion: "reduce" });
|
||||
expect(await store.updatePreferences(ada, { motion: "allow" })).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "allow",
|
||||
});
|
||||
expect(await store.updatePreferences(ada, { appearance: "light" })).toEqual({
|
||||
appearance: "light",
|
||||
motion: "allow",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores a first partial choice with the other preference still never chosen", async () => {
|
||||
expect(await store.updatePreferences(grace, { motion: "reduce" })).toEqual({
|
||||
appearance: "system",
|
||||
motion: "reduce",
|
||||
});
|
||||
const rows = await safeQuery<{ appearance: string | null; motion: string | null }>(
|
||||
admin,
|
||||
"select appearance, motion from ekklesia_core_user_preference where user_id = $1",
|
||||
[grace],
|
||||
);
|
||||
expect(rows).toEqual([{ appearance: null, motion: "reduce" }]);
|
||||
});
|
||||
|
||||
it("keeps each person's preferences to themselves", async () => {
|
||||
await store.updatePreferences(ada, { appearance: "dark", motion: "allow" });
|
||||
await store.updatePreferences(grace, { appearance: "light", motion: "reduce" });
|
||||
expect((await store.readAccount(ada))?.preferences).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "allow",
|
||||
});
|
||||
expect(await store.readAccount(grace)).toEqual({
|
||||
email: "[email protected]",
|
||||
name: "Grace Hopper",
|
||||
preferences: { appearance: "light", motion: "reduce" },
|
||||
});
|
||||
});
|
||||
|
||||
it("survives a completely new connection, as another device signing in would see", async () => {
|
||||
const reopened = createValidatedPostgresPool(database.url);
|
||||
try {
|
||||
const other = createAccountStore(createAccountDrizzleView(reopened.pool));
|
||||
expect((await other.readAccount(ada))?.preferences).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "allow",
|
||||
});
|
||||
} finally {
|
||||
await reopened.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to store preferences for a person who does not exist", async () => {
|
||||
const failure = await store
|
||||
.updatePreferences(nobody, { appearance: "dark" })
|
||||
.catch((error: unknown) => error);
|
||||
expect(failure).toBeInstanceOf(AccountStoreError);
|
||||
expect(failure).toMatchObject({ code: "ACCOUNT_NOT_FOUND" });
|
||||
const rows = await safeQuery(
|
||||
admin,
|
||||
"select 1 from ekklesia_core_user_preference where user_id = $1",
|
||||
[nobody],
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses an invalid update with nothing written", async () => {
|
||||
await expect(
|
||||
store.updatePreferences(ada, { appearance: "sepia" } as never),
|
||||
).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID" });
|
||||
expect((await store.readAccount(ada))?.preferences.appearance).toBe("dark");
|
||||
});
|
||||
|
||||
it("has the database itself refuse a value outside the model", async () => {
|
||||
await expect(
|
||||
admin.query(
|
||||
"update ekklesia_core_user_preference set appearance = 'sepia' where user_id = $1",
|
||||
[ada],
|
||||
),
|
||||
).rejects.toMatchObject({ code: "23514" });
|
||||
await expect(
|
||||
admin.query("update ekklesia_core_user_preference set motion = 'none' where user_id = $1", [
|
||||
ada,
|
||||
]),
|
||||
).rejects.toMatchObject({ code: "23514" });
|
||||
});
|
||||
|
||||
it("deletes a person's preferences with the person", async () => {
|
||||
await safeQuery(admin, "delete from ekklesia_core_user where id = $1", [grace]);
|
||||
const rows = await safeQuery(
|
||||
admin,
|
||||
"select 1 from ekklesia_core_user_preference where user_id = $1",
|
||||
[grace],
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
expect(await store.readAccount(grace)).toBeUndefined();
|
||||
await safeEnd(admin);
|
||||
await close();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
@@ -133,7 +134,13 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
|
||||
});
|
||||
|
||||
it("contains exactly these six permissions and no others", () => {
|
||||
it("registers core.account.self.manage as authenticated: managing your own account needs no grant", () => {
|
||||
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_ACCOUNT_SELF_MANAGE);
|
||||
expect(declaration?.baseline).toBe("authenticated");
|
||||
expect(declaration?.id).toBe("core.account.self.manage");
|
||||
});
|
||||
|
||||
it("contains exactly these seven permissions and no others", () => {
|
||||
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
@@ -141,6 +148,7 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -178,6 +186,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
|
||||
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||
id: CORE_DEVICES_MANAGE,
|
||||
},
|
||||
{
|
||||
baseline: "authenticated",
|
||||
description: "Read your own account and read and change your own preferences.",
|
||||
id: CORE_ACCOUNT_SELF_MANAGE,
|
||||
},
|
||||
]);
|
||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
||||
|
||||
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -243,6 +244,8 @@ describe.sequential("live Core authorization store", () => {
|
||||
"ekklesia_core_platform_owner_bootstrap",
|
||||
"ekklesia_core_session",
|
||||
"ekklesia_core_user",
|
||||
// A person's own preferences, one typed column each (ADR 0062).
|
||||
"ekklesia_core_user_preference",
|
||||
"ekklesia_core_verification",
|
||||
]);
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -92,7 +92,7 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
6 permissions registered
|
||||
7 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
|
||||
@@ -169,13 +169,57 @@ 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"))) {
|
||||
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");
|
||||
// the life of one form and nowhere else. The shell's uses of browser storage are pinned
|
||||
// exactly: the desktop sidebar's expanded/collapsed mode (ADR 0058) and the pre-paint copy of
|
||||
// the person's appearance (ADR 0062). `localStorage` is named only by the entry point, once, to
|
||||
// hand to those two, and by the pre-paint script, once, to read its one key. Each write is its
|
||||
// own fixed key and a validated value.
|
||||
const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src"));
|
||||
const writes: string[] = [];
|
||||
const removals: string[] = [];
|
||||
for (const file of files) {
|
||||
const code = withoutComments(file.text);
|
||||
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]}`,
|
||||
),
|
||||
);
|
||||
removals.push(
|
||||
...[...code.matchAll(/\.removeItem\(([^)]*)\)/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(
|
||||
/const browserStorage = \(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
|
||||
);
|
||||
expect(code).toContain("createLocalSidebarPreference(browserStorage)");
|
||||
expect(code).toContain("createLocalAppearanceCache(browserStorage)");
|
||||
} else if (file.path.endsWith(path.join("browser", "appearance-boot.ts"))) {
|
||||
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
|
||||
expect(code).toContain("window.localStorage.getItem(APPEARANCE_CACHE_KEY)");
|
||||
} else {
|
||||
expect(code, file.path).not.toContain("localStorage");
|
||||
}
|
||||
}
|
||||
expect(writes).toEqual([
|
||||
`${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY, serialized`,
|
||||
`${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`,
|
||||
]);
|
||||
expect(removals).toEqual([
|
||||
`${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY`,
|
||||
]);
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -4,8 +4,12 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
discoverModules,
|
||||
isReservedModuleId,
|
||||
ModuleDiscoveryError,
|
||||
type ModuleDiscoveryIssueCode,
|
||||
RESERVED_MODULE_IDS,
|
||||
RESERVED_SETTINGS_NAMES,
|
||||
RESERVED_TOP_LEVEL_NAMES,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
interface ModuleFixtureOptions {
|
||||
@@ -271,6 +275,89 @@ 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_TOP_LEVEL_NAMES).toEqual(["account", "api", "assets", "display", "settings"]);
|
||||
// A module's settings live at `/settings/<module-id>` (ADR 0061), beside the platform's own
|
||||
// Settings sections, so their names are reserved too.
|
||||
expect(RESERVED_SETTINGS_NAMES).toEqual([
|
||||
"branding",
|
||||
"discover",
|
||||
"displays",
|
||||
"general",
|
||||
"system",
|
||||
]);
|
||||
expect(RESERVED_MODULE_IDS).toEqual([
|
||||
"account",
|
||||
"api",
|
||||
"assets",
|
||||
"branding",
|
||||
"discover",
|
||||
"display",
|
||||
"displays",
|
||||
"general",
|
||||
"settings",
|
||||
"system",
|
||||
]);
|
||||
for (const list of [RESERVED_MODULE_IDS, RESERVED_TOP_LEVEL_NAMES, RESERVED_SETTINGS_NAMES]) {
|
||||
expect(Object.isFrozen(list)).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();
|
||||
// `modules` is an ordinary id again: module management is Settings → Discover.
|
||||
for (const id of [
|
||||
"settings-sync",
|
||||
"my-account",
|
||||
"display-board",
|
||||
"api-docs",
|
||||
"general-info",
|
||||
"modules",
|
||||
"discovery",
|
||||
"systems",
|
||||
]) {
|
||||
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",
|
||||
"discovery",
|
||||
"display-board",
|
||||
"general-info",
|
||||
"modules",
|
||||
"my-account",
|
||||
"settings-sync",
|
||||
"systems",
|
||||
]);
|
||||
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", {
|
||||
|
||||
@@ -182,6 +182,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
@@ -75,8 +76,9 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
||||
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
||||
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
|
||||
// Core's own five declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(8);
|
||||
expect(registry.has(CORE_ACCOUNT_SELF_MANAGE)).toBe(true);
|
||||
// Core's own seven declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(9);
|
||||
});
|
||||
|
||||
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
|
||||
@@ -97,6 +99,7 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
"calendar.events.read",
|
||||
"calendar.events.write",
|
||||
"people.directory.read",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+15
-3
@@ -241,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
|
||||
@@ -263,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 | |
|
||||
|
||||
@@ -339,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);
|
||||
@@ -405,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`. |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -151,6 +151,10 @@ reaching into the artifact or the artifact importing the host.
|
||||
|
||||
## Deferred
|
||||
|
||||
> **Cross-reference — see [ADR 0060](0060-light-dark-system-theme-and-motion.md).** A dark theme,
|
||||
> System appearance, reduced motion, and a radius scale have landed; ADR 0059 added the focus-trap
|
||||
> utility. The rest of this list is unchanged.
|
||||
|
||||
A theme editor, a brand builder, per-installation persisted overrides, a dark theme or any second
|
||||
theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a
|
||||
documentation site, and a focus-trap utility.
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# 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
|
||||
|
||||
> **Cross-reference — see
|
||||
> [ADR 0061](0061-settings-hierarchy-and-module-settings-routes.md).** The Settings pages and their
|
||||
> addresses have since been defined; the rest of this list is unchanged.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,189 @@
|
||||
# 0060: Light, Dark, and System appearance; reduced motion; and one radius scale
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-15
|
||||
- Extends: [0039](0039-shared-design-tokens-and-elements.md),
|
||||
[0040](0040-module-stylesheets-and-artifact-isolation.md),
|
||||
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
|
||||
[0059](0059-shared-interaction-primitives.md)
|
||||
|
||||
## Context
|
||||
|
||||
DESIGN.md requires every normal first-party interface to support Light, Dark, and System, with System
|
||||
the default and a person's choice syncing across their devices (§3.2, locked decisions 25–26);
|
||||
reduced motion following the operating system by default, with a personal override (§3.3, locked
|
||||
27); a configurable, bounded corner radius (§3.6); shared semantic tokens for every colour (§3.1);
|
||||
and WCAG AA (§24, locked 92).
|
||||
|
||||
ADR 0039 built the semantic token registry and explicitly deferred "a dark theme or any second theme".
|
||||
The registry had one value per token and was installed as a single `:root` rule. Modules already
|
||||
consumed it the right way: every module artifact mounts in an isolated shadow root (ADR 0040), and
|
||||
CSS custom properties inherit through shadow boundaries, so a module reads `var(--ekklesia-…)`
|
||||
without importing anything at run time.
|
||||
|
||||
Measured before this change, the light theme did not meet AA in two places: muted text was 4.10–4.43:1
|
||||
on the page, canvas, and surface (4.5:1 required), and form controls drew their boundary in
|
||||
`border.base`, 1.38:1 against a surface (3:1 required for a control's boundary, §1.4.11).
|
||||
|
||||
## Decision
|
||||
|
||||
### A token may vary by colour scheme, and by motion
|
||||
|
||||
`DesignTokenInput` gains two optional values, validated exactly like `value`:
|
||||
|
||||
- **`dark`** — the token's value in the dark scheme. Every colour, border colour, and shadow has one;
|
||||
lengths, weights, and font stacks do not, and keep one value.
|
||||
- **`reduced`** — the token's value when motion is reduced. Every motion token has one, and it is zero.
|
||||
|
||||
A registry built without either emits exactly the single rule it always did. The registry adds
|
||||
`valueFor(name, scheme)`.
|
||||
|
||||
### The stylesheet resolves the mode, in CSS
|
||||
|
||||
`installEkklesiaDesignTokens` installs one stylesheet that selects values by CSS alone:
|
||||
|
||||
```css
|
||||
:root { color-scheme: light; /* every default (light) value */ }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-ekklesia-appearance="light"]) { color-scheme: dark; /* dark values */ }
|
||||
}
|
||||
:root[data-ekklesia-appearance="dark"] { color-scheme: dark; /* dark values */ }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root:not([data-ekklesia-motion="allow"]) { /* motion durations: 0 */ }
|
||||
}
|
||||
:root[data-ekklesia-motion="reduce"] { /* motion durations: 0 */ }
|
||||
```
|
||||
|
||||
(and the same with `:host(…)` for an isolated root that has no inheriting ancestor).
|
||||
|
||||
Because the browser resolves it:
|
||||
|
||||
- **System is live.** An operating-system change restyles the page immediately — no script, no
|
||||
listener, no reload.
|
||||
- **An explicit choice wins.** `light` or `dark` on the theme root overrides the system; `system` (or
|
||||
no attribute) follows it.
|
||||
- **Modules follow without a contract change.** Custom properties and `color-scheme` inherit into
|
||||
every module's shadow root, so the same switch restyles module UI — Signage included — with no SDK
|
||||
change and no module release. This was verified in a real browser across the shadow boundary.
|
||||
- **Native UI follows too.** `color-scheme` goes with the tokens, so form controls and scrollbars
|
||||
match the scheme; nothing critical depends on browser-default styling.
|
||||
|
||||
### A small appearance API in `@ekklesia/design-tokens`
|
||||
|
||||
Framework-free and dependency-free, like the rest of the package:
|
||||
|
||||
- `EkklesiaAppearance` = `system` | `light` | `dark`; `EkklesiaMotionPreference` = `system` | `reduce` |
|
||||
`allow` — the model DESIGN.md asks for, including a future "allow" override of a reduced-motion
|
||||
system.
|
||||
- `parseEkklesiaAppearance` / `parseEkklesiaMotionPreference` read untrusted stored values; anything
|
||||
unrecognised is `system`.
|
||||
- `resolveEkklesiaColorScheme` / `resolveEkklesiaReducedMotion` say what is in effect.
|
||||
- `createEkklesiaThemeController({ target, matchMedia?, appearance?, motion? })` sets the two attributes
|
||||
**immediately** and answers `colorScheme()`, `reducedMotion()`, `setAppearance`, `setMotion`, and
|
||||
`subscribe` (which also reports system changes) for code that needs to know.
|
||||
|
||||
The host owns the choice. A module reads the theme through tokens and never sets appearance; nothing
|
||||
in the module UI contract changed.
|
||||
|
||||
### The shell applies it before React renders
|
||||
|
||||
`surfaces/shell/src/browser/main.tsx` creates the controller on `document.documentElement` before the
|
||||
first render, with `system` for both. That is the **bootstrap hook** for the account preferences
|
||||
work: it will read a remembered choice synchronously and pass it here, so an explicit Light or Dark is
|
||||
in place before the first frame. Nothing is persisted in this decision, and there is no temporary
|
||||
user-facing switch to remove later.
|
||||
|
||||
The Dashboard page declares `<meta name="color-scheme" content="light dark">` and takes its body
|
||||
background and text colour from tokens, so the browser's own canvas is already in the system's scheme
|
||||
before any script runs: a dark system never flashes white while the shell loads. (A future explicit
|
||||
choice opposite to the system can still show the system's empty canvas for the moment before the
|
||||
module script runs; that is the remaining cost of a page with no inline script.)
|
||||
|
||||
The **display host stays Light**, whatever its operating system prefers. A screen in a lobby is
|
||||
configured by whoever set it up, not by a person's appearance preference, and Stage 0 displays must
|
||||
look exactly as they did; motion there still follows the system.
|
||||
|
||||
### The dark theme is designed, not inverted
|
||||
|
||||
Teal-tinted charcoal backgrounds in an elevation ladder (page `#0e1615` → canvas → surface → raised),
|
||||
never pure black; primary text `#e4edeb`, never pure white; the same teal accent, slightly lifted, with
|
||||
dark text on it rather than a glowing control; status colours desaturated to sit on dark tints; deeper
|
||||
shadows; and a darker overlay. It is the same product in both schemes.
|
||||
|
||||
### Contrast is tested, and two light pairings were fixed
|
||||
|
||||
`packages/design-tokens/tests/contrast.test.ts` computes WCAG contrast for 41 pairings the elements
|
||||
actually use — text on every background tier, quiet and selected navigation, primary and danger
|
||||
buttons, field hints and errors, status notices and badges, control boundaries, and focus rings — in
|
||||
both schemes: 4.5:1 for text, 3:1 for control boundaries and focus indication. A real-browser check of
|
||||
rendered elements in both schemes agrees.
|
||||
|
||||
Two intentional, visible light-theme changes make it pass:
|
||||
|
||||
- `text.muted` darkens from `#6c7b77` to `#5d6d69` (5.44:1 on a surface).
|
||||
- A new **`border.control`** token (`#7a8d89`, 3.5:1) draws form controls' boundaries instead of
|
||||
`border.base`, which stays the quieter decorative border of cards and panels. `border.strong` is
|
||||
redefined to be stronger than `border.control` in both schemes, since it is the hovered control's
|
||||
border.
|
||||
|
||||
`background.placeholder` is new: the skeleton's colour, visibly distinct from the page and a surface
|
||||
in both schemes (it had used `background.inset`, which in a dark scheme is correctly darker than the
|
||||
page and made skeletons vanish).
|
||||
|
||||
### Motion and radius are tokens
|
||||
|
||||
A new **`motion`** category: `motion.duration-short` (160ms; transitions such as the sidebar and a
|
||||
toast arriving) and `motion.duration-ambient` (1.6s; a loading placeholder's pulse, and the spinner
|
||||
at 0.625 of it). Both are zero when motion is reduced, so an element that animates for a motion-token
|
||||
duration needs no reduced-motion rule of its own; the per-element rules in `@ekklesia/ui` and the
|
||||
shell are removed, which is also what lets an explicit `allow` work.
|
||||
|
||||
**`radius.scale`** (default `1`) multiplies `radius.sm`, `radius.md`, and `radius.lg`, which become
|
||||
`calc(<today's value> * var(--ekklesia-radius-scale))`; `radius.none` and `radius.pill` do not scale.
|
||||
At the default nothing looks different. A later appearance or branding control changes one value to
|
||||
make every corner squarer or rounder; it should offer a bounded range (DESIGN.md §3.6).
|
||||
|
||||
### Every Dashboard colour and corner is a token, and that is checked
|
||||
|
||||
The sign-in, verification, and recovery card (`AuthCard`) and the module loading and error text
|
||||
(`ModuleUiFrame`) carried literal colours; they now use tokens, and the sign-in action is the
|
||||
platform's primary button. `tests/theme-guards.test.ts` scans the shell's Dashboard source and
|
||||
`@ekklesia/ui` for raw colours and literal corner radii, and `@ekklesia/ui`'s own tests require its
|
||||
stylesheet's colours, radii, and durations to be tokens. The display host is the named exception,
|
||||
pinned light, until displays move to Settings → Displays.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Toggle a class from JavaScript on OS changes.** Rejected: CSS media queries already do it, live,
|
||||
in every isolated root, with nothing to wire up or forget to unsubscribe.
|
||||
- **A second token registry or stylesheet per theme.** Rejected: two registries drift; one token with
|
||||
two values cannot lose its dark half without a test failing.
|
||||
- **Automatic dark values (inverting, or adjusting lightness).** Rejected: an inverted theme is exactly
|
||||
the naive dark mode DESIGN.md warns against, and generated colours would not be reviewable.
|
||||
- **Inline bootstrap script to apply a stored choice before paint.** Rejected: the Dashboard has no
|
||||
inline script so a strict `script-src` needs no exception; the meta element covers the system case.
|
||||
- **Per-element `prefers-reduced-motion` rules.** Replaced: they cannot honour an explicit `allow`, and
|
||||
every new animation would have to remember one.
|
||||
- **Many radius tokens per component.** Rejected: one multiplier over a three-step scale is what a
|
||||
bounded "roundness" control needs.
|
||||
- **Letting the display host follow the system.** Rejected for now: it would change Stage 0 screens
|
||||
overnight when a kiosk's OS setting changes.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The Dashboard and every module UI are Light or Dark by the operating system today; an explicit choice
|
||||
is one call once the account preferences exist.
|
||||
- `@ekklesia/design-tokens` adds the `motion` category, `dark` and `reduced` values, `valueFor`, three
|
||||
tokens (`border.control`, `background.placeholder`, `radius.scale`) plus two motion tokens, and the
|
||||
appearance API. `@ekklesia/ui` uses them. Both changes are additive; code that switches exhaustively
|
||||
over `DesignTokenCategory` sees one more case.
|
||||
- Visible changes in light: slightly darker muted text and form-control borders, and the sign-in card
|
||||
in the platform's colours.
|
||||
|
||||
## Deferred
|
||||
|
||||
The Account → Appearance page and synced preference storage; a church theme editor and per-installation
|
||||
overrides of dark values; branding; the display host's own appearance and its pairing and offline
|
||||
screens' colours; the bounded radius range a future control offers; and exposing the resolved scheme
|
||||
to module code (modules read it through tokens today).
|
||||
@@ -0,0 +1,177 @@
|
||||
# 0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-15
|
||||
- Extends: [0027](0027-dashboard-navigation-and-flat-modules.md),
|
||||
[0028](0028-module-operational-lifecycle.md),
|
||||
[0029](0029-module-configuration-and-secrets.md),
|
||||
[0057](0057-dashboard-urls-are-host-owned-routes.md),
|
||||
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
|
||||
[0059](0059-shared-interaction-primitives.md)
|
||||
|
||||
## Context
|
||||
|
||||
DESIGN.md makes Settings the one home for shared administration (§9): church-wide configuration in
|
||||
Settings, personal configuration under the account area (§9.2), every module's settings reachable
|
||||
through Settings rather than an independent system inside the module (§9.3), and people without
|
||||
management permission not shown administration they cannot carry out (§9.2). ROADMAP §5.3 asks that
|
||||
the navigation and ownership model exist before every category is functional.
|
||||
|
||||
ADR 0057 reserved `/settings` and served the Dashboard page throughout it, but defined no page there.
|
||||
Three things had to be decided before any section could be built: the address of each page, how the
|
||||
Settings hierarchy sits in the shell's one sidebar (ADR 0058), and how the browser learns which
|
||||
Settings destinations a person may see — it knew only that they were signed in, plus each module's
|
||||
own permission hints (ADR 0026).
|
||||
|
||||
## Decision
|
||||
|
||||
### The URL model
|
||||
|
||||
```text
|
||||
/settings the Settings landing page
|
||||
/settings/general General: how this installation works for everyone
|
||||
/settings/branding Branding: the church's name, logo, and colours
|
||||
/settings/displays Displays: paired screens
|
||||
/settings/discover enters Discover at Browse (respelled in place, below)
|
||||
/settings/discover/browse Browse: find and install modules
|
||||
/settings/discover/installed Installed: see and manage installed modules
|
||||
/settings/discover/updates Updates: module and platform updates
|
||||
/settings/system System: version, health, and diagnostics
|
||||
/settings/<module-id> that module's settings
|
||||
/settings/<module-id>/… reserved for a module's deeper settings pages
|
||||
```
|
||||
|
||||
The distinction is the decision:
|
||||
|
||||
- **`/settings/discover/…` is module lifecycle** — finding, installing, turning on and off, inspecting,
|
||||
updating, and eventually removing modules.
|
||||
- **`/settings/<module-id>` is configuring that module.** It is never nested under Discover or under a
|
||||
`modules` segment: a module's settings are a first-class Settings destination.
|
||||
|
||||
**"Modules" is the product's word**, in the interface as in the architecture — never "features",
|
||||
"apps", "extensions", or "add-ons". "Discover" is the name of a navigation destination, not a
|
||||
replacement for it: inside Discover the pages say "Discover modules", "Installed modules", and "Module
|
||||
updates". Implementation terms — Core, a platform module — never appear.
|
||||
|
||||
**Entering Discover opens Browse, at one address.** `/settings/discover` reads as Browse, whose
|
||||
canonical spelling is `/settings/discover/browse`; the shell respells it in place (`replace`, no new
|
||||
history entry), exactly as it corrects a trailing slash (ADR 0057). There is one meaningful Browse
|
||||
address rather than two, and Discover's own item in the navigation links to it.
|
||||
|
||||
**`/settings` is a real landing page**, never a redirect to the first section: the two groups the
|
||||
person may open, each item named and described in a line. It is an index, not a dashboard.
|
||||
|
||||
Anything deeper than these addresses is `not-found` in the shell until its page exists. The API already
|
||||
serves the page throughout `/settings/*`, and the proxies already forward it (ADR 0057), so deeper pages
|
||||
need no server or deployment change.
|
||||
|
||||
### Module ids cannot collide with a Settings section
|
||||
|
||||
A module id is a **stable URL identifier**: its page is `/<module-id>` and its settings
|
||||
`/settings/<module-id>`. So the platform's own Settings section names join the top-level names in Core's
|
||||
one list, `RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`), now in two documented groups:
|
||||
|
||||
| Group | Names |
|
||||
| --- | --- |
|
||||
| Top-level namespaces (`/<name>`) | `account`, `api`, `assets`, `display`, `settings` |
|
||||
| Settings sections (`/settings/<name>`) | `branding`, `discover`, `displays`, `general`, `system` |
|
||||
|
||||
Discovery refuses a module that declares any of them (`MODULE_ID_RESERVED`), at startup, in
|
||||
`pnpm modules:validate`, and in package verification. Look-alikes stay valid — `general-info`,
|
||||
`discovery`, `display-board`, `systems` — and so does `modules`, since module management is Discover.
|
||||
Discover's views live beneath `/settings/discover/`, so they cannot collide with a module and are not
|
||||
reserved. `apps/api` tests hold Core's Settings group equal to the shell's `SETTINGS_SECTIONS`, so a
|
||||
section cannot be added to one without the other.
|
||||
|
||||
### Settings sits in the same sidebar
|
||||
|
||||
Settings is one item in the shell's one navigation (ADR 0058), after the modules and above the account
|
||||
area. While a Settings page is open it expands in place into two labelled groups — **Platform**
|
||||
(General, Branding, Displays, Discover, System) and **Modules** (each module's settings) — and inside
|
||||
Discover, Browse, Installed, and Updates beneath it. The rest of the navigation stays where it is, so
|
||||
leaving Settings is one click on any other item; it is not a second application with its own shell.
|
||||
The phone drawer shows the identical hierarchy. Exactly one item carries `aria-current="page"`.
|
||||
|
||||
### Which destinations a person sees is evaluated by the server
|
||||
|
||||
`GET /api/v1/dashboard/settings` (`apps/api/src/dashboard-settings-routes.ts`), readable by any signed-in
|
||||
person (`core.modules.ui.read`, like the navigation beside it), answers which sections and which modules'
|
||||
settings this person may see, through Core's one authorization evaluator:
|
||||
|
||||
| Destination | Shown to |
|
||||
| --- | --- |
|
||||
| Displays | `core.devices.manage` |
|
||||
| Discover | `core.modules.lifecycle.manage` — deciding which modules run |
|
||||
| a module's settings | `core.modules.configuration.manage` (ADR 0029), for every module that declares configuration |
|
||||
| General, Branding, System | the **platform owner** only, until each defines its own permission |
|
||||
|
||||
General, Branding, and System have no permission yet because nothing in them exists to authorize.
|
||||
Rather than invent three speculative permissions, they are shown only to the platform owner — the one
|
||||
principal who already holds every permission — and each gains a delegable permission in the work that
|
||||
builds it. Platform updates in Discover → Updates will likewise get their own permission then.
|
||||
|
||||
A module **has settings** when its manifest declares configuration (ADR 0029). That is the existing,
|
||||
authoritative signal; no manifest change is needed. A disabled or blocked module with settings is listed
|
||||
with `available: false`.
|
||||
|
||||
These are **presentation hints, not authority**, as ADR 0026's are. Every route behind a section keeps
|
||||
its own enforcement, unchanged, and a test proves the data behind each is still refused without its
|
||||
permission. An evaluation failure is a `503`, never an empty answer that would look like a permissions
|
||||
problem. The browser decides nothing about permissions itself.
|
||||
|
||||
### What each Settings address shows
|
||||
|
||||
- A section or module the person may see shows its page.
|
||||
- A section the person may not see, or a module they know of but cannot configure (or that has no
|
||||
settings), shows *These settings aren't available* — without saying which.
|
||||
- A disabled module's settings show *This module is turned off*. Never a silent redirect.
|
||||
- A name that is no module the person knows of is *Page not found*.
|
||||
- When the Settings view cannot be loaded, the Settings link is withheld and its pages say so, with
|
||||
Retry; the rest of the Dashboard keeps working.
|
||||
- When a person can see nothing in Settings, the Settings link is not shown at all.
|
||||
|
||||
### Pages are honest placeholders
|
||||
|
||||
Every section, Discover view, and module's settings page in this decision is a placeholder: Back to
|
||||
Settings (`PageHeader`, ADR 0059), a title and one-line description, and *Not available yet* with what
|
||||
will happen there. No fake controls, no dead fields. The work that builds each replaces its placeholder.
|
||||
|
||||
Back goes to the page's parent, Settings. A validated return target (ADR 0059) takes precedence once
|
||||
something carries one, so `/signage` → `/settings/signage` can return to `/signage`; how a module links
|
||||
into its settings with a return target is the module navigation contract's to define.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **`/settings/modules` for module management, and `/settings/modules/<module-id>` for a module's
|
||||
settings.** Rejected: it nests a module's configuration under its lifecycle and makes "configure
|
||||
Signage" two levels deep. Discover is lifecycle; `/settings/<module-id>` is configuration.
|
||||
- **`/settings/discover` as a page of its own beside `/settings/discover/browse`.** Rejected: two
|
||||
meaningful addresses for one view.
|
||||
- **Redirecting `/settings` to the first section.** Rejected: a landing page says what Settings holds,
|
||||
and the first section differs per person.
|
||||
- **Replacing the module navigation with the Settings hierarchy while in Settings.** Rejected: DESIGN.md
|
||||
keeps one sidebar with Settings in it (§4.2), and leaving Settings should not require a second
|
||||
navigation model.
|
||||
- **Probing each section's API for a `403` from the browser.** Rejected: several requests per page view,
|
||||
and it confuses "may not" with "failed".
|
||||
- **Defining General, Branding, and System permissions now.** Rejected as speculative; the owner-only
|
||||
interim rule is deny-by-default with the owner's existing bypass.
|
||||
- **A manifest field declaring "has settings".** Unnecessary: declared configuration already says so;
|
||||
module-owned settings pages arrive with the module navigation contract.
|
||||
- **Reserving Discover's view names.** Unnecessary: they live under `/settings/discover/`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every later Settings section has an address, a navigation entry, a permission rule, and a placeholder
|
||||
to replace.
|
||||
- A module may not be called `branding`, `discover`, `displays`, `general`, or `system`. No known module
|
||||
is; one that was would fail discovery and must be renamed.
|
||||
- The shell asks one more endpoint at bootstrap, alongside the navigation.
|
||||
- A person without any administrative permission sees no Settings link.
|
||||
|
||||
## Deferred
|
||||
|
||||
The real General, Branding, Displays, Discover (catalog, installation, lifecycle, updates), and System
|
||||
pages, and each one's permission; module settings screens and their deeper pages; how a module links to
|
||||
its settings with a validated return target (the module navigation contract, manifest v9); moving the
|
||||
transitional device panel into Settings → Displays; and the account area.
|
||||
@@ -0,0 +1,199 @@
|
||||
# 0062: The account area, and appearance preferences that belong to the account
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-14
|
||||
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||
[0044](0044-self-hosted-reference-deployment.md),
|
||||
[0045](0045-split-dashboard-and-api-origins.md),
|
||||
[0057](0057-dashboard-urls-are-host-owned-routes.md),
|
||||
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
|
||||
[0060](0060-light-dark-system-theme-and-motion.md),
|
||||
[0061](0061-settings-hierarchy-and-module-settings-routes.md)
|
||||
|
||||
## Context
|
||||
|
||||
DESIGN.md puts the signed-in person at the bottom of the sidebar (§4.5), keeps personal configuration
|
||||
under that account area rather than in church-wide Settings (§9.2), and requires Light, Dark, and
|
||||
System appearance with the person's choice **syncing across their devices** (§3.2, locked decision
|
||||
25), System being the default (locked decision 26).
|
||||
|
||||
ADR 0060 built the theme: the token stylesheet resolves appearance and motion in CSS, a controller sets
|
||||
two attributes on the theme root, and `main.tsx` is the bootstrap hook where a remembered choice would
|
||||
be supplied. It deferred the Account → Appearance page and synced storage, and it noted the remaining
|
||||
cost of a page with no inline script: an explicit choice opposite to the system can show the system's
|
||||
empty canvas until the module script runs.
|
||||
|
||||
Three things were missing. The shell knew the person only as an opaque user id
|
||||
(`/api/v1/principal`), so the account control could not say who was signed in. There was nowhere to
|
||||
keep a personal preference. And the Dashboard page is one frozen snapshot with one `ETag`, served in
|
||||
the split topology from a hostname that never receives the API's session cookie (ADR 0045), so the
|
||||
server cannot write a person's choice into the HTML.
|
||||
|
||||
## Decision
|
||||
|
||||
### The account area
|
||||
|
||||
```text
|
||||
/account Account: who you are signed in as, and your own settings
|
||||
/account/appearance Appearance: Light, Dark, or System; and reduced motion
|
||||
```
|
||||
|
||||
Personal, never church administration. It needs no management permission, it sits outside the Settings
|
||||
hierarchy (ADR 0061), and the sidebar's Settings group does not expand on it. `account` was already a
|
||||
reserved top-level name (ADR 0057), so the API already served the page here and no server or proxy
|
||||
change was needed for the addresses. Account sections live beneath `/account/`, so they cannot collide
|
||||
with a module id and are not reserved. Anything else under `/account/` is *Page not found* until it
|
||||
exists.
|
||||
|
||||
**The account control names the person**: their initials, their name, and their email, at the bottom
|
||||
of the sidebar and in the phone drawer. Its menu offers exactly the destinations that work: Account,
|
||||
Appearance, and Sign out. Account and Appearance are real links. When the account cannot be loaded, the
|
||||
control falls back to "Your account" and keeps every destination.
|
||||
|
||||
**No avatar image.** The account's `image` is an arbitrary URL the platform has never vetted.
|
||||
Rendering it would make every Dashboard visit a request to whatever host it names, and it has no CSP
|
||||
story. Initials are drawn from the name instead, or from the email when the name is blank. Showing a
|
||||
person's picture needs an upload and serving path the platform owns, which is later work.
|
||||
|
||||
There is **no profile editing**. Name and email are shown as Better Auth stores them, and nothing on
|
||||
the page pretends to be editable.
|
||||
|
||||
### Preferences belong to the account
|
||||
|
||||
One Core table, one row per person, **one typed column per preference**:
|
||||
|
||||
```text
|
||||
ekklesia_core_user_preference
|
||||
user_id text primary key, references ekklesia_core_user(id) on delete cascade
|
||||
appearance text null, or one of 'system' | 'light' | 'dark' (CHECK)
|
||||
motion text null, or one of 'system' | 'reduce' | 'allow' (CHECK)
|
||||
created_at, updated_at
|
||||
```
|
||||
|
||||
- **`null` means never chosen** and resolves to the default (System, System) when read. A column
|
||||
default would make "chose System" indistinguishable from "never chose". It would also force a value
|
||||
nobody chose onto every existing row when a preference is added. A person with no row has chosen
|
||||
nothing. Reading never creates a row.
|
||||
- **A new preference is a new nullable column** in a new migration, beside these. That is not one
|
||||
table per preference, and it is not a key/value or JSON document a client could fill with anything.
|
||||
Values are checked twice: by Core's strict parser (`parseAccountPreferencesUpdate`) and by the
|
||||
database's `CHECK` constraints.
|
||||
- Core declares the value sets itself (`ACCOUNT_APPEARANCES`, `ACCOUNT_MOTION_PREFERENCES`), because
|
||||
Core cannot import the design tokens. An `apps/api` test holds them equal to `EKKLESIA_APPEARANCES`
|
||||
and `EKKLESIA_MOTION_PREFERENCES`.
|
||||
- Migration `core:0008_user_preferences` creates the table and destroys nothing. Like every migration,
|
||||
it is applied only by an operator (`pnpm migrations:apply`).
|
||||
|
||||
The sidebar's expanded or collapsed mode is **not** moved here. It stays a preference of this browser
|
||||
on this device (ADR 0058).
|
||||
|
||||
### The API is self-only by construction
|
||||
|
||||
| Route | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/account` | none | `{ name, email, preferences: { appearance, motion } }` |
|
||||
| `PATCH /api/v1/account/preferences` | `{ appearance?, motion? }`, at least one | `{ appearance, motion }`, everything now in effect |
|
||||
|
||||
- **Neither route accepts a user id**, not in the path, the query, or the body. The account is always
|
||||
the one the session names, so no request can read or change anyone else's, whatever the caller holds.
|
||||
A body naming any key besides a known preference, or any value outside its set, is a `400` with
|
||||
nothing changed.
|
||||
- **`PATCH` changes only what it names.** Changing motion on a phone never overwrites an appearance just
|
||||
chosen on a laptop.
|
||||
- **Both require `core.account.self.manage`**, a new Core permission with an `authenticated` baseline.
|
||||
Every signed-in person holds it, and choosing dark mode needs no grant. It is a declared permission
|
||||
rather than a hand-written "is signed in" check, following `core.modules.ui.read` (ADR 0026). A paired
|
||||
device never satisfies `authenticated`, and an anonymous caller is refused; both get `403`. The route
|
||||
also checks that the principal is a person before reading.
|
||||
- The mutation takes an exact trusted `Origin` and a bounded body before authorization, like every Core
|
||||
mutation. Responses are `no-store` and vary by `Origin, Cookie`.
|
||||
- **The browser learns name, email, and preferences, and nothing else**: not the image URL,
|
||||
verification state, timestamps, or user id.
|
||||
|
||||
### First paint: a classic pre-paint script, never inline
|
||||
|
||||
The server cannot render the choice into the page. The page is one snapshot with one validator for
|
||||
everyone, and in the split topology its hostname never sees the session cookie. So:
|
||||
|
||||
1. The page loads `/assets/ekklesia-dashboard/appearance.js` in `<head>`, after
|
||||
`<meta name="color-scheme">`. It is a classic, same-origin, render-blocking script of about a
|
||||
kilobyte, and **never inline**, so a strict `script-src` still needs no exception. ADR 0060 rejected
|
||||
an inline script for exactly that reason. It is a fixed asset route beside `app.js` with its own
|
||||
strong `ETag`. The proxies already forward `/assets/ekklesia-dashboard/*`.
|
||||
2. It reads this browser's **cached copy** of the choice: one key, `ekklesia.dashboard.appearance`,
|
||||
holding two words (for example `dark reduce`). It sets the two theme attributes on the root. For an
|
||||
explicit Light or Dark it also narrows the colour-scheme element, so the browser's canvas is painted
|
||||
in that scheme before the token stylesheet arrives. An invalid, missing, or unreadable cache does
|
||||
nothing, and the page follows the system.
|
||||
3. `main.tsx` starts the ADR 0060 controller from the same cache, so the two agree.
|
||||
|
||||
**The cache is never the source of truth.** Every Dashboard load replaces it with what the account
|
||||
holds, so a choice made on another device takes effect here from the next load on. A choice is saved to
|
||||
the account first-class: it applies at once, the account's answer is what stays applied, and a failed
|
||||
save puts the account's value back and says so. The cache holds nothing about the account. It is
|
||||
cleared on sign-out and whenever the page resolves to signed-out, so the next person to use the browser
|
||||
starts from the system's setting. An account that cannot be loaded leaves the cache alone rather than
|
||||
guessing.
|
||||
|
||||
This removes the cost ADR 0060 noted. An explicit choice is in place from the first frame on any browser
|
||||
that has loaded the Dashboard before. A first visit, a new device, or a blocked storage follows the
|
||||
system until the account answers, moments later.
|
||||
|
||||
### Changes apply live, and other tabs follow
|
||||
|
||||
A choice takes effect through `setAppearance` and `setMotion` on the one controller, with no reload and
|
||||
no Save button. Isolated module roots follow because the attributes and custom properties inherit, as in
|
||||
ADR 0060.
|
||||
|
||||
**Other tabs** of the Dashboard in the same browser follow through the browser's `storage` event on the
|
||||
cache key, with no request. Sign-out in one tab returns the others to the system as well.
|
||||
**Cross-device realtime is not provided**: another device picks up a change on its next load. That meets
|
||||
DESIGN.md's "syncs across devices" without a push channel, which would cost a persistent connection per
|
||||
open Dashboard for a setting people change rarely.
|
||||
|
||||
### Unchanged
|
||||
|
||||
- **The display host stays Light** (ADR 0060). It loads neither the pre-paint script nor the account.
|
||||
A lobby screen is not a person.
|
||||
- Module UIs read the theme through tokens and never set it, and the module UI contract is unchanged.
|
||||
- No church theme, brand colours, radius control, administrator-enforced mode, or Signage appearance.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Render the choice into the HTML on the server.** Rejected: one frozen snapshot and `ETag` for
|
||||
everyone (ADR 0044), and in the split topology the Dashboard hostname never receives the session
|
||||
cookie (ADR 0045). Per-person HTML would also put account data into a cacheable page.
|
||||
- **An inline bootstrap script.** Rejected, as in ADR 0060: it costs a strict `script-src` an exception.
|
||||
A same-origin external script does the same job.
|
||||
- **Treat `localStorage` as the preference store.** Rejected: it does not follow the person to another
|
||||
device, and it would survive into the next person's session on a shared computer.
|
||||
- **A cookie carrying the choice.** Rejected: in the split topology a cookie the Dashboard origin could
|
||||
read would be a second, cross-origin cookie policy for one preference. It would also travel on every
|
||||
request.
|
||||
- **A key/value or JSON preference table.** Rejected: values the server cannot type-check, and a column
|
||||
any client could fill. One typed, constrained column per preference costs one migration per
|
||||
preference, which is the point.
|
||||
- **One table per preference.** Rejected: many tables for one row per person.
|
||||
- **Fold the account into `/api/v1/principal`.** Rejected: the principal is the platform's identity
|
||||
primitive, also read by the display host and the module UI host. Name and email are the account area's
|
||||
concern, behind their own permission.
|
||||
- **Load the account's `image`.** Rejected, as above.
|
||||
- **Realtime cross-device sync.** Deferred: next-load reconciliation meets the requirement.
|
||||
- **Move the sidebar mode to the account.** Not done: it is a per-device preference (ADR 0058).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Core gains `@ekklesia/core/account` (schema, store, value sets), migration `0008_user_preferences`,
|
||||
and a seventh Core permission, `core.account.self.manage`. Every composed registry and the Doctor's
|
||||
count grow by one.
|
||||
- The Dashboard serves three host-owned files instead of two, and bootstrap makes one more request,
|
||||
alongside the navigation and Settings. An account that fails to load never stops the Dashboard.
|
||||
- Browser storage now holds two things: the sidebar mode and the appearance cache. A boundary test pins
|
||||
each read and write.
|
||||
- No public `@ekklesia/*` package changes.
|
||||
|
||||
## Deferred
|
||||
|
||||
Profile editing, an uploaded avatar the platform serves itself, notification and other personal
|
||||
preferences, which arrive as further columns. Also deferred: realtime cross-device sync, a church theme
|
||||
or administrator-enforced appearance, and the display host's own appearance.
|
||||
@@ -65,3 +65,9 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [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
|
||||
- [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted
|
||||
- [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted
|
||||
- [0062: The account area, and appearance preferences that belong to the account](0062-account-area-and-synced-appearance-preferences.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,29 @@ 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)). Every colour token has a designed dark
|
||||
value, and the token stylesheet selects Light, Dark, or the system's scheme — and zeroes motion
|
||||
durations when motion is reduced — in CSS alone, so the Dashboard and every module root follow the
|
||||
operating system live; the host chooses an explicit appearance through a small controller, applied
|
||||
before React renders. Contrast is tested for every pairing the elements use, in both schemes
|
||||
([ADR 0060](decisions/0060-light-dark-system-theme-and-motion.md)). Settings is one host-owned
|
||||
hierarchy in the same sidebar: a landing page, the platform's General, Branding, Displays, Discover
|
||||
(module lifecycle: Browse, Installed, Updates), and System, and each module's settings at
|
||||
`/settings/<module-id>`. Which of them a person sees is evaluated by the server
|
||||
(`GET /api/v1/dashboard/settings`) as presentation hints, while every route behind them keeps its own
|
||||
enforcement; the Settings section names are reserved module ids
|
||||
([ADR 0061](decisions/0061-settings-hierarchy-and-module-settings-routes.md)). Personal configuration
|
||||
lives apart from it, in the account area: `/account` shows who is signed in, and `/account/appearance`
|
||||
holds Light, Dark, or System and reduced motion. These preferences belong to the account, stored by
|
||||
Core one typed column per preference and served only to their owner (`GET /api/v1/account`,
|
||||
`PATCH /api/v1/account/preferences`, `core.account.self.manage`), so they follow the person to every
|
||||
device. A small same-origin script in the page's `<head>` applies this browser's cached copy before
|
||||
first paint, and the account's answer replaces it on every load
|
||||
([ADR 0062](decisions/0062-account-area-and-synced-appearance-preferences.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
|
||||
|
||||
+74
-14
@@ -79,7 +79,8 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
|
||||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||||
| `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), with the API's origin written into it. Served from the API's origin, or from the Dashboard's own hostname through the proxy in the split topology. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's application script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /assets/ekklesia-dashboard/appearance.js` | Ekklesia | The Dashboard's small pre-paint appearance script, loaded in `<head>`. `no-cache` with its own strong `ETag`. See [Dashboard host](#dashboard-host) and [Account](#account). |
|
||||
| `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||
| `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
|
||||
@@ -87,6 +88,8 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
|
||||
| `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's HTTP and realtime roots live, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). |
|
||||
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
|
||||
| `GET /api/v1/account` | Ekklesia | The signed-in person's own name, email, and preferences. Requires `core.account.self.manage` (baseline `authenticated`). Takes no user id. See [Account](#account). |
|
||||
| `PATCH /api/v1/account/preferences` | Ekklesia | Change some of the signed-in person's own preferences. Requires `core.account.self.manage` and a trusted `Origin`. See [Account](#account). |
|
||||
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
|
||||
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
|
||||
| `GET /api/v1/module-ui/<module-id>/<version>/display` | Ekklesia | Publishes the exact validated display artifact for that eligible/running module. Artifact bytes are anonymous trusted code; this route is not the authorization boundary. See [Module display host](#module-display-host). |
|
||||
@@ -147,14 +150,16 @@ same-origin topology it does so from the same origin as `/api`: `EKKLESIA_DASHBO
|
||||
unset, the session cookie stays host-only, and no second static server or proxy file root is
|
||||
needed. In the split topology
|
||||
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the proxy forwards
|
||||
the Dashboard's own hostname to the same two routes; the API does not inspect `Host`. It works
|
||||
the Dashboard's own hostname to the same routes; the API does not inspect `Host`. It works
|
||||
exactly like the display host:
|
||||
|
||||
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the
|
||||
exact URLs of two built files, `dist/browser/index.html` and
|
||||
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any
|
||||
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256
|
||||
strong `ETag` for each.
|
||||
exact URLs of three built files: `dist/browser/index.html`,
|
||||
`dist/browser/assets/ekklesia-dashboard/app.js`, and the pre-paint
|
||||
`dist/browser/assets/ekklesia-dashboard/appearance.js`
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
|
||||
`startApi()` reads all three before importing any module code or binding the listener, and keeps
|
||||
one frozen snapshot of their text with a SHA-256 strong `ETag` for each.
|
||||
- **The API's origin is written in at startup.** The built page carries one empty
|
||||
`<meta content="" name="ekklesia-api-origin" />`. Before computing the page's `ETag`, `startApi()`
|
||||
fills it with the identity base URL's origin, which is where `/api` lives in either topology. The
|
||||
@@ -166,33 +171,35 @@ exactly like the display host:
|
||||
`DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
|
||||
already opened. It never carries a path or the filesystem error. Build the shell first;
|
||||
`pnpm api:start` already runs the full build.
|
||||
- **Two exact routes, never a directory.** `GET /` answers the page and
|
||||
`GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and
|
||||
- **Exact routes, never a directory.** `GET /` (and the Dashboard's own pages, ADR 0057) answers
|
||||
the page, `GET /assets/ekklesia-dashboard/app.js` the application script, and
|
||||
`GET /assets/ekklesia-dashboard/appearance.js` the pre-paint script. No request value selects a
|
||||
file, and
|
||||
nothing touches the filesystem while answering. Every other path, including `/index.html`,
|
||||
`/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary
|
||||
`404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client
|
||||
router, and emailed links land on `/` with a query (see
|
||||
[The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's
|
||||
implicit `HEAD`) are also the ordinary `404`.
|
||||
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two
|
||||
files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
|
||||
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the
|
||||
served files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
|
||||
a `404` for it.
|
||||
|
||||
Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's
|
||||
CORS policy does not apply. The bytes grant no authority: the page renders sign-in until
|
||||
`/api/v1/principal` says otherwise.
|
||||
|
||||
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` |
|
||||
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` and `appearance.js` |
|
||||
| --- | --- | --- |
|
||||
| `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` |
|
||||
| `Cache-Control` | `no-cache` | `no-cache` |
|
||||
| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script |
|
||||
| `ETag` | strong SHA-256 of the page | strong SHA-256 of each script |
|
||||
| `X-Content-Type-Options` | `nosniff` | `nosniff` |
|
||||
| `X-Frame-Options` | `DENY` | — |
|
||||
| `Content-Security-Policy` | `frame-ancestors 'none'` | — |
|
||||
| `Referrer-Policy` | `no-referrer` | — |
|
||||
|
||||
**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every
|
||||
**Caching.** The shell emits fixed, unhashed file names, so every file is revalidated on every
|
||||
load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match`
|
||||
(strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and
|
||||
`Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry
|
||||
@@ -233,7 +240,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 +268,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,
|
||||
@@ -1206,6 +1225,47 @@ platform-global; Ekklesia has no organization or tenant model to scope it to yet
|
||||
|
||||
Responses are per-principal, so `Cache-Control: no-store` and `Vary: Origin, Cookie`.
|
||||
|
||||
## Account
|
||||
|
||||
The signed-in person's own account
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)): who
|
||||
they are signed in as, and their personal preferences, which follow them to every device.
|
||||
|
||||
| Route | Body | `200` answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/account` | none | `{"name":"Ada Lovelace","email":"[email protected]","preferences":{"appearance":"dark","motion":"system"}}` |
|
||||
| `PATCH /api/v1/account/preferences` | `{"appearance":"light"}`, `{"motion":"reduce"}`, or both | `{"appearance":"light","motion":"system"}`, everything now in effect |
|
||||
|
||||
- **Self-only by construction.** Neither route accepts a user id in the path, query, or body. The
|
||||
account is always the session's, so no request can read or change anyone else's.
|
||||
- **Values.** `appearance` is one of `system`, `light`, or `dark`. `motion` is one of `system`,
|
||||
`reduce`, or `allow`. A person who never chose gets `system` for both.
|
||||
- **`PATCH` is partial.** It changes only the preferences it names and keeps the rest, so two devices
|
||||
changing different preferences never overwrite each other. A body that is empty, names any other key,
|
||||
or uses any other value is refused whole with `400 {"code":"INVALID_REQUEST"}`, and nothing is
|
||||
written.
|
||||
- **What it returns.** Name and email exactly as the account holds them, and nothing else about the
|
||||
account: no image URL, verification state, timestamps, or user id.
|
||||
- **Authority.** Both routes require `core.account.self.manage`, whose baseline is `authenticated`, so
|
||||
every signed-in person may use them with no grant. An anonymous caller or a paired device gets
|
||||
`403 {"code":"AUTHORIZATION_DENIED"}`. The `PATCH` also needs an exact trusted `Origin`
|
||||
(`403 {"code":"UNTRUSTED_ORIGIN"}`) and a body within the Core mutation limit (`413`), both checked
|
||||
before authorization.
|
||||
- **Failures.** A person whose session outlived their account gets `404 {"code":"ACCOUNT_NOT_FOUND"}`.
|
||||
A storage failure is a sanitized `503` (`ACCOUNT_READ_FAILED` or `ACCOUNT_WRITE_FAILED`).
|
||||
- **Headers.** Responses are `Cache-Control: no-store` and `Vary: Origin, Cookie`.
|
||||
|
||||
**Storage.** One row per person in `ekklesia_core_user_preference`, with one typed, `CHECK`-constrained
|
||||
column per preference. `null` means never chosen, and deleting the person deletes the row. It is created
|
||||
by migration `core:0008_user_preferences`, which an operator applies with `pnpm migrations:apply`. Until
|
||||
it is applied, the account routes answer a sanitized `503`, and the rest of the Dashboard keeps working.
|
||||
|
||||
**In the browser.** The Dashboard loads the account at bootstrap and applies its preferences through
|
||||
the ADR 0060 theme controller. It keeps a two-word copy (`ekklesia.dashboard.appearance` in
|
||||
`localStorage`) for one purpose: so `appearance.js` can apply the choice before the first paint. That
|
||||
copy is never the source of truth. It is replaced by the account's answer on every load, cleared on
|
||||
sign-out, and followed by other open tabs through the browser's `storage` event.
|
||||
|
||||
## Module UI inventory
|
||||
|
||||
```text
|
||||
|
||||
@@ -112,6 +112,8 @@ list:
|
||||
| `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. |
|
||||
| `core.modules.lifecycle.manage` | `restricted` | Read and change which installed modules are enabled. |
|
||||
| `core.modules.configuration.manage` | `restricted` | Read and change an installed module's own configuration values. |
|
||||
| `core.devices.manage` | `restricted` | Approve display device pairing, list paired devices, and revoke a device. |
|
||||
| `core.account.self.manage` | `authenticated` | Read your own account and read and change your own preferences. |
|
||||
|
||||
Every Core permission is a **platform** permission and can never be declared, gated, or influenced by
|
||||
a module — `core.*` is a reserved namespace (see below). `core.modules.lifecycle.manage` gates both
|
||||
@@ -122,6 +124,13 @@ milder version of the write, because the lifecycle view is the only place a *dis
|
||||
`core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority
|
||||
inside any module.
|
||||
|
||||
`core.account.self.manage` is the one Core permission every signed-in person holds by baseline
|
||||
besides `core.modules.ui.read`: choosing your own appearance is not church administration
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)). It is
|
||||
**self-only by construction** rather than by a check: the account routes take the account from the
|
||||
session and accept no user id at all, so the permission, however it is held, confers nothing over
|
||||
anyone else's account. A device never satisfies its `authenticated` baseline.
|
||||
|
||||
`core.modules.configuration.manage` is deliberately **separate from** the lifecycle permission
|
||||
([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)): deciding whether a
|
||||
module runs and deciding how it behaves are different authorities, and someone trusted to tune a
|
||||
@@ -235,7 +244,7 @@ generic explicit-grant HTTP API, enforcement middleware, and now its own mounted
|
||||
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
|
||||
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
|
||||
it never re-discovers modules itself. A successful composition reports a pass with a safe
|
||||
declaration count (for example `5 permissions registered` with zero installed modules, since Core's
|
||||
declaration count (for example `7 permissions registered` with zero installed modules, since Core's
|
||||
own declarations are always present); zero installed modules still composes successfully. If module
|
||||
discovery itself failed, the check is skipped, matching the existing schema/migration checks'
|
||||
pattern; if discovery succeeded but composition failed, schema and migration checks still run
|
||||
|
||||
@@ -136,7 +136,7 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
6 permissions registered
|
||||
7 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
@@ -146,7 +146,7 @@ Data definitions
|
||||
✓ Schemas validated
|
||||
0 modules · 0 tables
|
||||
✓ Migrations validated
|
||||
7 migrations · checksums valid
|
||||
9 migrations · checksums valid
|
||||
core:0000_identity_core_tables · f8f2086ef06f
|
||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||
core:0002_authorization_integrity · 634294731180
|
||||
@@ -154,6 +154,8 @@ Data definitions
|
||||
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
||||
core:0005_module_configuration · c8d57f24f84b
|
||||
core:0006_device_identity · 1db97b7e3ac5
|
||||
core:0007_verified_bootstrap_owners · 49013062f3e0
|
||||
core:0008_user_preferences · fa9d4b0bb9d3
|
||||
|
||||
PostgreSQL
|
||||
✓ Database TLS verified
|
||||
|
||||
@@ -425,6 +425,28 @@ enforced by `requirePermission` against the composed platform permission registr
|
||||
enforcement neither knows nor cares what a browser decided. Hiding a control is a usability
|
||||
improvement, never an access control. Treat any claim that a UI check protects anything as a bug.
|
||||
|
||||
### A person's account is theirs alone, and the browser's copy of it is not authority
|
||||
|
||||
The account area
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md))
|
||||
returns a signed-in person's name, email, and preferences, and lets them change their preferences. It
|
||||
is **self-only by construction**: `GET /api/v1/account` and `PATCH /api/v1/account/preferences` take
|
||||
the account from the session and accept no user id anywhere, so no request can name another person.
|
||||
Both routes require `core.account.self.manage` (baseline `authenticated`), which no device satisfies.
|
||||
The mutation requires an exact trusted `Origin` and a bounded body, like every Core mutation.
|
||||
|
||||
- **Nothing more leaves the server.** The account's image URL, verification state, timestamps, and
|
||||
user id are never returned.
|
||||
- **No avatar image is loaded.** The account's `image` is an arbitrary, unvetted URL, and fetching it
|
||||
would disclose every Dashboard visit to whatever host it names. Initials are drawn instead.
|
||||
- **Nothing about the account is in the HTML.** The Dashboard page is still one snapshot for everyone.
|
||||
The pre-paint `appearance.js` is a same-origin external script, so the page still has no inline
|
||||
script and a strict `script-src` needs no exception.
|
||||
- **The browser keeps two words.** It stores the appearance and motion words under
|
||||
`ekklesia.dashboard.appearance` for the next first paint. That copy holds nothing about the account,
|
||||
is validated strictly before use, is replaced by the account's answer on every load, and is cleared
|
||||
on sign-out. Tampering with it changes nothing but this browser's colours until the account answers.
|
||||
|
||||
## What this platform must not claim
|
||||
|
||||
Ekklesia today has **none** of the following, and no document, README, or marketing surface may
|
||||
|
||||
@@ -403,7 +403,37 @@ 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 uses of browser storage are exactly two: the sidebar's expanded/collapsed mode
|
||||
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)) and the
|
||||
pre-paint appearance cache
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
|
||||
`localStorage` is named once by the entry point and once, read-only, by the pre-paint script. There is
|
||||
one write of each fixed key, one removal of the appearance key, and no storage API in any file that
|
||||
holds the reset token.
|
||||
|
||||
The account area
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)) is
|
||||
proven at each layer:
|
||||
|
||||
- `core/tests/account-preferences.test.ts` covers the closed preference model, its strict parser, the
|
||||
table's typed columns, and that the migration's `CHECK` lists equal the model.
|
||||
- `core/tests/account-store.integration.ts` uses real PostgreSQL. It proves defaults without writing a
|
||||
row, saving each value, partial updates, isolation between two people, persistence across a new
|
||||
connection, the database refusing an out-of-model value, and the cascade on delete.
|
||||
- `apps/api/tests/account-routes.test.ts` covers the routes. Anonymous and device callers are refused,
|
||||
only the session's account is ever read or written whatever the request names, there is no route
|
||||
that addresses another person, and validation, `Origin`, body limit, failures, and CORS preflight
|
||||
all behave as specified. It also holds Core's value sets equal to the design tokens'.
|
||||
- `apps/api/tests/api-http.integration.ts` runs two real accounts over real HTTP sessions, with the
|
||||
same isolation proven end to end.
|
||||
- `surfaces/shell/tests/appearance-preference.test.ts` covers the cache, the pre-paint script (run in
|
||||
the DOM), and the one handle over the ADR 0060 controller.
|
||||
- `surfaces/shell/tests/account-appearance.test.tsx` covers the account control's identity, both
|
||||
pages, live switching, saving, reverting on failure, reconciling with the account, cross-tab
|
||||
following, sign-out forgetting, and the phone drawer.
|
||||
- The deploy smoke's real Chromium checks Light and Dark rendering, reload persistence, an emulated OS
|
||||
change, reduced motion, a module's Shadow DOM following the theme, and phone width.
|
||||
|
||||
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:
|
||||
|
||||
@@ -535,7 +535,20 @@ 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` at the top level, and
|
||||
`branding`, `discover`, `displays`, `general`, and `system`, the platform's own Settings sections. Your
|
||||
module id is a **stable URL identifier**: if your module declares `configuration`, its settings live at
|
||||
`/settings/<module-id>` — `/settings/calendar` for this one — as a first-class Settings destination,
|
||||
visible to whoever holds `core.modules.configuration.manage`
|
||||
([ADR 0061](../architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)).
|
||||
Module lifecycle — installing, turning on and off, updating — is the platform's Settings → Discover, not
|
||||
yours to build. Do not build a second settings system inside your module; link to your Settings page
|
||||
instead once the module navigation contract can carry a return link. 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.
|
||||
@@ -571,6 +584,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
|
||||
@@ -879,10 +903,60 @@ The host installs the default token values at its own root, and CSS custom prope
|
||||
through an isolated artifact's boundary — so the tokens are simply there, with nothing to import at
|
||||
run time and nothing for the host to hand you.
|
||||
|
||||
**Light, Dark, and System come with the tokens**
|
||||
([ADR 0060](../architecture/decisions/0060-light-dark-system-theme-and-motion.md)). The host decides
|
||||
the appearance, and the token values change under you — live when the person's system switches —
|
||||
inside your isolated root too. Your UI is themed for free as long as every colour it uses is a token:
|
||||
never assume a white background, black text, or one brand colour, and never set the appearance
|
||||
yourself. `color-scheme` follows as well, so native inputs and scrollbars match. Colours that
|
||||
represent *content* rather than interface — a preview of what a physical screen shows, an uploaded
|
||||
image — are yours to keep literal.
|
||||
|
||||
**Animate for a motion token, not a literal duration.** `motion.duration-short` (transitions) and
|
||||
`motion.duration-ambient` (a gentle, continuous indication) are zero when motion is reduced — by the
|
||||
system or by the person's preference — so an animation that uses them needs no
|
||||
`prefers-reduced-motion` rule of its own, and honours an explicit "allow motion" that such a rule
|
||||
would ignore:
|
||||
|
||||
```css
|
||||
.calendar-panel { transition: opacity var(--ekklesia-motion-duration-short) ease; }
|
||||
```
|
||||
|
||||
**Round corners with `radius.sm`, `radius.md`, or `radius.lg`.** They scale together from one
|
||||
`radius.scale` value, so a church's chosen roundness reaches your UI without you changing anything.
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
DESIGN_TOKEN_APPEARANCE_ATTRIBUTE,
|
||||
DESIGN_TOKEN_MOTION_ATTRIBUTE,
|
||||
type DesignTokenColorScheme,
|
||||
} from "./token.js";
|
||||
|
||||
/**
|
||||
* A person's appearance preference (DESIGN.md §3.2, ADR 0060). `system` follows the operating
|
||||
* system's light or dark setting, live; it is the default.
|
||||
*/
|
||||
export const EKKLESIA_APPEARANCES = Object.freeze(["system", "light", "dark"] as const);
|
||||
export type EkklesiaAppearance = (typeof EKKLESIA_APPEARANCES)[number];
|
||||
|
||||
/**
|
||||
* A person's motion preference (DESIGN.md §3.3, ADR 0060). `system` follows
|
||||
* `prefers-reduced-motion`; `reduce` always reduces; `allow` keeps the platform's subtle motion even
|
||||
* when the system asks for less.
|
||||
*/
|
||||
export const EKKLESIA_MOTION_PREFERENCES = Object.freeze(["system", "reduce", "allow"] as const);
|
||||
export type EkklesiaMotionPreference = (typeof EKKLESIA_MOTION_PREFERENCES)[number];
|
||||
|
||||
export const EKKLESIA_DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";
|
||||
export const EKKLESIA_REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
/** Reads an untrusted stored or received value; anything unrecognised is the default, `system`. */
|
||||
export function parseEkklesiaAppearance(value: unknown): EkklesiaAppearance {
|
||||
return EKKLESIA_APPEARANCES.includes(value as EkklesiaAppearance)
|
||||
? (value as EkklesiaAppearance)
|
||||
: "system";
|
||||
}
|
||||
|
||||
/** Reads an untrusted stored or received value; anything unrecognised is the default, `system`. */
|
||||
export function parseEkklesiaMotionPreference(value: unknown): EkklesiaMotionPreference {
|
||||
return EKKLESIA_MOTION_PREFERENCES.includes(value as EkklesiaMotionPreference)
|
||||
? (value as EkklesiaMotionPreference)
|
||||
: "system";
|
||||
}
|
||||
|
||||
/** The scheme an appearance shows, given whether the system currently prefers dark. */
|
||||
export function resolveEkklesiaColorScheme(
|
||||
appearance: EkklesiaAppearance,
|
||||
systemPrefersDark: boolean,
|
||||
): DesignTokenColorScheme {
|
||||
if (appearance === "system") {
|
||||
return systemPrefersDark ? "dark" : "light";
|
||||
}
|
||||
return appearance;
|
||||
}
|
||||
|
||||
/** Whether motion is reduced, given the preference and whether the system currently asks for less. */
|
||||
export function resolveEkklesiaReducedMotion(
|
||||
preference: EkklesiaMotionPreference,
|
||||
systemPrefersReduced: boolean,
|
||||
): boolean {
|
||||
return preference === "system" ? systemPrefersReduced : preference === "reduce";
|
||||
}
|
||||
|
||||
/** The element a host themes: its document's root element, or an isolated root's host element. */
|
||||
export interface EkklesiaThemeTarget {
|
||||
setAttribute(name: string, value: string): void;
|
||||
}
|
||||
|
||||
/** A media query list, structurally: what `window.matchMedia` returns. */
|
||||
export interface EkklesiaMediaQueryList {
|
||||
readonly matches: boolean;
|
||||
addEventListener(type: "change", listener: () => void): void;
|
||||
removeEventListener(type: "change", listener: () => void): void;
|
||||
}
|
||||
|
||||
export interface EkklesiaThemeControllerOptions {
|
||||
readonly target: EkklesiaThemeTarget;
|
||||
/** The browser's `matchMedia`, handed in by the host. Without one, the system is taken to prefer
|
||||
* light and full motion. */
|
||||
readonly matchMedia?: (query: string) => EkklesiaMediaQueryList;
|
||||
readonly appearance?: EkklesiaAppearance;
|
||||
readonly motion?: EkklesiaMotionPreference;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's handle on its theme (ADR 0060).
|
||||
*
|
||||
* The **stylesheet** does the work: installed tokens resolve light, dark, and reduced motion in CSS
|
||||
* from the system's media features and two attributes on the theme root, so a change of OS setting
|
||||
* restyles everything at once — every isolated module root included, since custom properties and
|
||||
* `color-scheme` inherit — with no script and no reload. This controller only sets those attributes,
|
||||
* and answers what is currently in effect for code that needs to know.
|
||||
*/
|
||||
export interface EkklesiaThemeController {
|
||||
appearance(): EkklesiaAppearance;
|
||||
motion(): EkklesiaMotionPreference;
|
||||
/** The scheme on screen now: the explicit appearance, or the system's while it is `system`. */
|
||||
colorScheme(): DesignTokenColorScheme;
|
||||
/** Whether motion is reduced now. */
|
||||
reducedMotion(): boolean;
|
||||
setAppearance(appearance: EkklesiaAppearance): void;
|
||||
setMotion(motion: EkklesiaMotionPreference): void;
|
||||
/** Notifies after a preference changes, and after the system's setting changes. */
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a theme to its target **immediately** — before anything renders, which is what keeps a
|
||||
* persisted explicit choice from flashing the other scheme first — and returns its controller.
|
||||
* Preferences are taken through {@link parseEkklesiaAppearance} and
|
||||
* {@link parseEkklesiaMotionPreference}, so an untrusted stored value can be passed straight in.
|
||||
*/
|
||||
export function createEkklesiaThemeController(
|
||||
options: EkklesiaThemeControllerOptions,
|
||||
): EkklesiaThemeController {
|
||||
let appearance = parseEkklesiaAppearance(options.appearance);
|
||||
let motion = parseEkklesiaMotionPreference(options.motion);
|
||||
const darkQuery = options.matchMedia?.(EKKLESIA_DARK_SCHEME_QUERY);
|
||||
const motionQuery = options.matchMedia?.(EKKLESIA_REDUCED_MOTION_QUERY);
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = (): void => {
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
options.target.setAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, appearance);
|
||||
options.target.setAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE, motion);
|
||||
|
||||
return {
|
||||
appearance: () => appearance,
|
||||
colorScheme: () => resolveEkklesiaColorScheme(appearance, darkQuery?.matches ?? false),
|
||||
motion: () => motion,
|
||||
reducedMotion: () => resolveEkklesiaReducedMotion(motion, motionQuery?.matches ?? false),
|
||||
setAppearance(next) {
|
||||
const parsed = parseEkklesiaAppearance(next);
|
||||
if (parsed === appearance) {
|
||||
return;
|
||||
}
|
||||
appearance = parsed;
|
||||
options.target.setAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, appearance);
|
||||
notify();
|
||||
},
|
||||
setMotion(next) {
|
||||
const parsed = parseEkklesiaMotionPreference(next);
|
||||
if (parsed === motion) {
|
||||
return;
|
||||
}
|
||||
motion = parsed;
|
||||
options.target.setAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE, motion);
|
||||
notify();
|
||||
},
|
||||
subscribe(listener) {
|
||||
if (listeners.size === 0) {
|
||||
darkQuery?.addEventListener("change", notify);
|
||||
motionQuery?.addEventListener("change", notify);
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
darkQuery?.removeEventListener("change", notify);
|
||||
motionQuery?.removeEventListener("change", notify);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./appearance.js";
|
||||
export * from "./install.js";
|
||||
export * from "./registry.js";
|
||||
export * from "./token.js";
|
||||
|
||||
@@ -12,9 +12,12 @@ import {
|
||||
* every consumer's meaning changing with it: `background.raised` stays "the surface that sits above
|
||||
* an ordinary surface" whatever a church's brand makes it.
|
||||
*
|
||||
* The default values are Ekklesia's calm, teal-accented light theme. They remain semantic defaults —
|
||||
* a deployment overrides them by installing different values at its host root, and this pass
|
||||
* deliberately ships no theme editor, brand builder, or dark-mode variant to do that with.
|
||||
* The default values are Ekklesia's calm, teal-accented light theme, and each colour carries a
|
||||
* `dark` value: a designed dark theme of the same product — teal-tinted charcoal rather than black,
|
||||
* text that is never pure white, an accent that stays the same teal rather than glowing — selected by
|
||||
* `prefers-color-scheme` or by the host's explicit appearance (ADR 0060). Every text and focus
|
||||
* pairing a component uses meets WCAG 2.2 AA in both, and `tests/contrast.test.ts` holds that.
|
||||
* There is still no theme editor or brand builder; a deployment overrides values at its host root.
|
||||
*
|
||||
* ## Status is never colour alone
|
||||
*
|
||||
@@ -32,6 +35,7 @@ const declarations = [
|
||||
label: "Page background",
|
||||
name: "background.page",
|
||||
value: "#f3f7f6",
|
||||
dark: "#0e1615",
|
||||
},
|
||||
{
|
||||
description:
|
||||
@@ -39,30 +43,43 @@ const declarations = [
|
||||
label: "Canvas background",
|
||||
name: "background.canvas",
|
||||
value: "#f8faf9",
|
||||
dark: "#121b1a",
|
||||
},
|
||||
{
|
||||
description: "An ordinary panel, card, table, or dialog body sitting on the page.",
|
||||
label: "Surface background",
|
||||
name: "background.surface",
|
||||
value: "#ffffff",
|
||||
dark: "#162120",
|
||||
},
|
||||
{
|
||||
description: "A surface deliberately lifted above an ordinary surface, such as a popover.",
|
||||
label: "Raised surface background",
|
||||
name: "background.raised",
|
||||
value: "#ffffff",
|
||||
dark: "#1c2826",
|
||||
},
|
||||
{
|
||||
description: "The scrim drawn behind a modal dialog to separate it from the page beneath.",
|
||||
label: "Overlay background",
|
||||
name: "background.overlay",
|
||||
value: "rgba(23, 23, 21, 0.55)",
|
||||
dark: "rgba(3, 8, 7, 0.68)",
|
||||
},
|
||||
{
|
||||
description: "A recessed area such as a text input, a code block, or a well.",
|
||||
label: "Inset background",
|
||||
name: "background.inset",
|
||||
value: "#edf3f1",
|
||||
dark: "#0b1211",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The shape of content that is still loading — a skeleton placeholder. Visibly distinct from the page and from a surface in both schemes.",
|
||||
label: "Placeholder background",
|
||||
name: "background.placeholder",
|
||||
value: "#e3ebe9",
|
||||
dark: "#243230",
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -73,42 +90,49 @@ const declarations = [
|
||||
label: "Primary text",
|
||||
name: "text.primary",
|
||||
value: "#182321",
|
||||
dark: "#e4edeb",
|
||||
},
|
||||
{
|
||||
description: "Supporting text that is still content: captions, secondary columns, help text.",
|
||||
label: "Secondary text",
|
||||
name: "text.secondary",
|
||||
value: "#4f5f5b",
|
||||
dark: "#b4c3bf",
|
||||
},
|
||||
{
|
||||
description: "De-emphasised text such as placeholders, timestamps, and disabled labels.",
|
||||
label: "Muted text",
|
||||
name: "text.muted",
|
||||
value: "#6c7b77",
|
||||
value: "#5d6d69",
|
||||
dark: "#93a5a0",
|
||||
},
|
||||
{
|
||||
description: "Text drawn on a strongly coloured background, such as a primary button.",
|
||||
label: "Inverse text",
|
||||
name: "text.inverse",
|
||||
value: "#ffffff",
|
||||
dark: "#0e1615",
|
||||
},
|
||||
{
|
||||
description: "Text reporting a successful outcome, paired with the success symbol.",
|
||||
label: "Success text",
|
||||
name: "text.success",
|
||||
value: "#166534",
|
||||
dark: "#86d9a7",
|
||||
},
|
||||
{
|
||||
description: "Text reporting a caution or a degraded state, paired with the warning symbol.",
|
||||
label: "Warning text",
|
||||
name: "text.warning",
|
||||
value: "#854d0e",
|
||||
dark: "#e9c37a",
|
||||
},
|
||||
{
|
||||
description: "Text reporting a failure or a destructive consequence, paired with its symbol.",
|
||||
label: "Danger text",
|
||||
name: "text.danger",
|
||||
value: "#9f1239",
|
||||
dark: "#f5a6b6",
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -119,42 +143,49 @@ const declarations = [
|
||||
label: "Accent",
|
||||
name: "accent.base",
|
||||
value: "#4fb3a8",
|
||||
dark: "#5bbcb1",
|
||||
},
|
||||
{
|
||||
description: "The accent background under a hovered primary action.",
|
||||
label: "Accent hover",
|
||||
name: "accent.hover",
|
||||
value: "#5ab9ae",
|
||||
dark: "#6cc5bb",
|
||||
},
|
||||
{
|
||||
description: "The accent background while a primary action is pressed.",
|
||||
label: "Accent active",
|
||||
name: "accent.active",
|
||||
value: "#45a59b",
|
||||
dark: "#4ea99f",
|
||||
},
|
||||
{
|
||||
description: "A deeper accent for pressed primary actions and high-emphasis emphasis.",
|
||||
label: "Strong accent",
|
||||
name: "accent.strong",
|
||||
value: "#246b64",
|
||||
dark: "#82d3c8",
|
||||
},
|
||||
{
|
||||
description: "A faint accent wash for selected rows and subtle accent backgrounds.",
|
||||
label: "Subtle accent",
|
||||
name: "accent.subtle",
|
||||
value: "#def2ef",
|
||||
dark: "#163330",
|
||||
},
|
||||
{
|
||||
description: "Foreground drawn on top of the accent background.",
|
||||
label: "Accent foreground",
|
||||
name: "accent.foreground",
|
||||
value: "#102a27",
|
||||
dark: "#04201d",
|
||||
},
|
||||
{
|
||||
description: "The focus ring colour. Never the only signal that something has focus.",
|
||||
label: "Focus ring",
|
||||
name: "accent.focus",
|
||||
value: "#287a72",
|
||||
dark: "#7fd4c9",
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -165,18 +196,29 @@ const declarations = [
|
||||
label: "Border",
|
||||
name: "border.base",
|
||||
value: "#d3dedb",
|
||||
dark: "#2b3836",
|
||||
},
|
||||
{
|
||||
description: "A heavier border for emphasis, active inputs, and selected outlines.",
|
||||
label: "Strong border",
|
||||
name: "border.strong",
|
||||
value: "#91a6a1",
|
||||
value: "#637571",
|
||||
dark: "#8a9c98",
|
||||
},
|
||||
{
|
||||
description: "A hairline rule separating rows or sections inside one surface.",
|
||||
label: "Divider",
|
||||
name: "border.divider",
|
||||
value: "#e2eae8",
|
||||
dark: "#222e2c",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The boundary of a form control — an input, a select — which must stand out at least 3:1 against what surrounds it (WCAG 2.2 §1.4.11).",
|
||||
label: "Control border",
|
||||
name: "border.control",
|
||||
value: "#7a8d89",
|
||||
dark: "#667a76",
|
||||
},
|
||||
{
|
||||
description: "The border width used by controls and panels.",
|
||||
@@ -193,12 +235,14 @@ const declarations = [
|
||||
label: "Success background",
|
||||
name: "status.success-background",
|
||||
value: "#dcfce7",
|
||||
dark: "#12301f",
|
||||
},
|
||||
{
|
||||
description: "Border of a success badge or banner.",
|
||||
label: "Success border",
|
||||
name: "status.success-border",
|
||||
value: "#86efac",
|
||||
dark: "#2f6e49",
|
||||
},
|
||||
{
|
||||
description: "The non-colour signal for success, rendered beside the label.",
|
||||
@@ -211,12 +255,14 @@ const declarations = [
|
||||
label: "Warning background",
|
||||
name: "status.warning-background",
|
||||
value: "#fef3c7",
|
||||
dark: "#33280f",
|
||||
},
|
||||
{
|
||||
description: "Border of a warning badge or banner.",
|
||||
label: "Warning border",
|
||||
name: "status.warning-border",
|
||||
value: "#fcd34d",
|
||||
dark: "#7c6121",
|
||||
},
|
||||
{
|
||||
description: "The non-colour signal for a warning, rendered beside the label.",
|
||||
@@ -229,12 +275,14 @@ const declarations = [
|
||||
label: "Danger background",
|
||||
name: "status.danger-background",
|
||||
value: "#ffe4e6",
|
||||
dark: "#3b1a22",
|
||||
},
|
||||
{
|
||||
description: "Border of a danger badge or banner.",
|
||||
label: "Danger border",
|
||||
name: "status.danger-border",
|
||||
value: "#fda4af",
|
||||
dark: "#8c3648",
|
||||
},
|
||||
{
|
||||
description: "The non-colour signal for danger or failure, rendered beside the label.",
|
||||
@@ -247,12 +295,14 @@ const declarations = [
|
||||
label: "Info background",
|
||||
name: "status.info-background",
|
||||
value: "#def2ef",
|
||||
dark: "#14322e",
|
||||
},
|
||||
{
|
||||
description: "Border of a neutral informational badge or banner.",
|
||||
label: "Info border",
|
||||
name: "status.info-border",
|
||||
value: "#9bd5ce",
|
||||
dark: "#2f6c63",
|
||||
},
|
||||
{
|
||||
description: "The non-colour signal for information, rendered beside the label.",
|
||||
@@ -269,24 +319,28 @@ const declarations = [
|
||||
label: "Hover background",
|
||||
name: "state.hover-background",
|
||||
value: "#edf3f1",
|
||||
dark: "#1f2c2a",
|
||||
},
|
||||
{
|
||||
description: "Background wash under a control being pressed.",
|
||||
label: "Pressed background",
|
||||
name: "state.pressed-background",
|
||||
value: "#e1ebe8",
|
||||
dark: "#273634",
|
||||
},
|
||||
{
|
||||
description: "Background of a selected row, tab, or list item.",
|
||||
label: "Selected background",
|
||||
name: "state.selected-background",
|
||||
value: "#def2ef",
|
||||
dark: "#183b37",
|
||||
},
|
||||
{
|
||||
description: "Foreground of a selected row, tab, or list item.",
|
||||
label: "Selected foreground",
|
||||
name: "state.selected-foreground",
|
||||
value: "#205f59",
|
||||
dark: "#a3e6dc",
|
||||
},
|
||||
{
|
||||
description: "Opacity applied to a disabled control. Disabled state is never colour alone.",
|
||||
@@ -462,23 +516,30 @@ const declarations = [
|
||||
name: "radius.none",
|
||||
value: "0",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"How rounded every corner is, as one multiplier: 1 is the default, 0 is square. Small, medium, and large radii scale with it; none and pill do not.",
|
||||
label: "Corner roundness",
|
||||
name: "radius.scale",
|
||||
value: "1",
|
||||
},
|
||||
{
|
||||
description: "Corner radius for badges and small inline elements.",
|
||||
label: "Small radius",
|
||||
name: "radius.sm",
|
||||
value: "0.25rem",
|
||||
value: "calc(0.25rem * var(--ekklesia-radius-scale))",
|
||||
},
|
||||
{
|
||||
description: "Corner radius for controls, inputs, and buttons.",
|
||||
label: "Medium radius",
|
||||
name: "radius.md",
|
||||
value: "0.625rem",
|
||||
value: "calc(0.625rem * var(--ekklesia-radius-scale))",
|
||||
},
|
||||
{
|
||||
description: "Corner radius for panels and dialogs.",
|
||||
label: "Large radius",
|
||||
name: "radius.lg",
|
||||
value: "0.875rem",
|
||||
value: "calc(0.875rem * var(--ekklesia-radius-scale))",
|
||||
},
|
||||
{
|
||||
description: "Fully rounded ends, for pills and status dots.",
|
||||
@@ -497,18 +558,21 @@ const declarations = [
|
||||
label: "Small shadow",
|
||||
name: "shadow.sm",
|
||||
value: "0 1px 3px rgba(24, 35, 33, 0.08)",
|
||||
dark: "0 1px 3px rgba(0, 0, 0, 0.45)",
|
||||
},
|
||||
{
|
||||
description: "Shadow for a popover or a raised surface.",
|
||||
label: "Medium shadow",
|
||||
name: "shadow.md",
|
||||
value: "0 8px 24px rgba(24, 35, 33, 0.12)",
|
||||
dark: "0 8px 24px rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
{
|
||||
description: "Shadow for a modal dialog.",
|
||||
label: "Large shadow",
|
||||
name: "shadow.lg",
|
||||
value: "0 18px 48px rgba(24, 35, 33, 0.2)",
|
||||
dark: "0 18px 48px rgba(0, 0, 0, 0.6)",
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -538,6 +602,27 @@ const declarations = [
|
||||
name: "control.padding-inline",
|
||||
value: "0.75rem",
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Motion — every duration an element animates for. Each is zero when motion is reduced (ADR 0060),
|
||||
// so an element that uses these tokens needs no reduced-motion rule of its own.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
{
|
||||
description:
|
||||
"A quick, purposeful transition: a sidebar collapsing, a notification arriving. Zero when motion is reduced.",
|
||||
label: "Short duration",
|
||||
name: "motion.duration-short",
|
||||
value: "160ms",
|
||||
reduced: "0ms",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"One cycle of a gentle, continuous indication that something is happening, such as a loading placeholder. Zero when motion is reduced.",
|
||||
label: "Ambient duration",
|
||||
name: "motion.duration-ambient",
|
||||
value: "1.6s",
|
||||
reduced: "0s",
|
||||
},
|
||||
] as const satisfies readonly DesignTokenInput[];
|
||||
|
||||
/** Every registered token name, as a union — the compile-time half of "modules consume registered
|
||||
|
||||
@@ -26,6 +26,7 @@ export const DESIGN_TOKEN_CATEGORIES = [
|
||||
"radius",
|
||||
"shadow",
|
||||
"control",
|
||||
"motion",
|
||||
] as const;
|
||||
|
||||
export type DesignTokenCategory = (typeof DESIGN_TOKEN_CATEGORIES)[number];
|
||||
@@ -82,8 +83,18 @@ export interface DesignTokenInput {
|
||||
readonly label: string;
|
||||
/** What the token means and when to reach for it. Enumerable, never rendered by the platform. */
|
||||
readonly description: string;
|
||||
/** The default CSS value installed at a host root. */
|
||||
/** The default CSS value installed at a host root: the light value, for a colour. */
|
||||
readonly value: string;
|
||||
/**
|
||||
* The value in the dark colour scheme, when it differs (ADR 0060). A token without one keeps its
|
||||
* value in both schemes — a length, a weight, a font stack. Validated exactly like `value`.
|
||||
*/
|
||||
readonly dark?: string;
|
||||
/**
|
||||
* The value when motion is reduced (ADR 0060), for a motion token: a duration of zero. A token
|
||||
* without one is unaffected by the motion preference. Validated exactly like `value`.
|
||||
*/
|
||||
readonly reduced?: string;
|
||||
}
|
||||
|
||||
/** A validated token: its declaration plus the two derived facts callers actually consume. */
|
||||
@@ -191,6 +202,12 @@ function validateToken(input: DesignTokenInput): DesignToken {
|
||||
if (!isTokenValue(input.value)) {
|
||||
throw new DesignTokenError("DESIGN_TOKEN_VALUE_INVALID");
|
||||
}
|
||||
if (input.dark !== undefined && !isTokenValue(input.dark)) {
|
||||
throw new DesignTokenError("DESIGN_TOKEN_VALUE_INVALID");
|
||||
}
|
||||
if (input.reduced !== undefined && !isTokenValue(input.reduced)) {
|
||||
throw new DesignTokenError("DESIGN_TOKEN_VALUE_INVALID");
|
||||
}
|
||||
return Object.freeze({
|
||||
category: category as DesignTokenCategory,
|
||||
cssVariable,
|
||||
@@ -198,6 +215,8 @@ function validateToken(input: DesignTokenInput): DesignToken {
|
||||
label: input.label,
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
...(input.dark === undefined ? {} : { dark: input.dark }),
|
||||
...(input.reduced === undefined ? {} : { reduced: input.reduced }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -206,6 +225,22 @@ function validateToken(input: DesignTokenInput): DesignToken {
|
||||
* an arbitrary selector string is exactly the unbounded value this package refuses everywhere else. */
|
||||
export type DesignTokenScope = "root" | "host";
|
||||
|
||||
/** The colour schemes a token can carry a value for. */
|
||||
export type DesignTokenColorScheme = "light" | "dark";
|
||||
|
||||
/**
|
||||
* The attribute a host sets on its theme root to choose an appearance (ADR 0060): `light` or `dark`
|
||||
* forces that scheme; anything else, including its absence, follows `prefers-color-scheme`.
|
||||
*/
|
||||
export const DESIGN_TOKEN_APPEARANCE_ATTRIBUTE = "data-ekklesia-appearance";
|
||||
|
||||
/**
|
||||
* The attribute a host sets on its theme root to choose motion (ADR 0060): `reduce` forces reduced
|
||||
* motion; `allow` keeps full motion even when the system asks for less; anything else, including its
|
||||
* absence, follows `prefers-reduced-motion`.
|
||||
*/
|
||||
export const DESIGN_TOKEN_MOTION_ATTRIBUTE = "data-ekklesia-motion";
|
||||
|
||||
export interface DesignTokenRegistry<Name extends string = string> {
|
||||
/** Every token, in declaration order. Stable, frozen, and the enumeration a theme editor reads. */
|
||||
list(): readonly DesignToken[];
|
||||
@@ -219,10 +254,52 @@ export interface DesignTokenRegistry<Name extends string = string> {
|
||||
variable(name: Name): string;
|
||||
/** `var(--ekklesia-…)` for a registered token — what component code actually writes. */
|
||||
reference(name: Name): string;
|
||||
/** The complete default theme as one CSS rule, ready to install at a host root. */
|
||||
/** The value of a registered token in one colour scheme. Throws `DesignTokenError` otherwise. */
|
||||
valueFor(name: Name, scheme: DesignTokenColorScheme): string;
|
||||
/**
|
||||
* The complete theme, ready to install at a host root: every default value, and — when the
|
||||
* registry has them — the dark values and the reduced-motion values, each selected by CSS alone
|
||||
* (ADR 0060). A registry with neither emits exactly one rule.
|
||||
*/
|
||||
stylesheet(scope?: DesignTokenScope): string;
|
||||
}
|
||||
|
||||
function declarationBlock(
|
||||
tokens: readonly DesignToken[],
|
||||
pick: (token: DesignToken) => string | undefined,
|
||||
extra: readonly string[] = [],
|
||||
): string {
|
||||
const lines = tokens.flatMap((token) => {
|
||||
const value = pick(token);
|
||||
return value === undefined ? [] : [` ${token.cssVariable}: ${value};`];
|
||||
});
|
||||
return [...extra.map((line) => ` ${line};`), ...lines].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The selectors one scope uses. `root` is a document; `host` is an isolated root with no inheriting
|
||||
* ancestor, whose host element carries the attributes. Fixed strings — never a caller's selector.
|
||||
*/
|
||||
function selectorsFor(scope: DesignTokenScope) {
|
||||
const appearance = DESIGN_TOKEN_APPEARANCE_ATTRIBUTE;
|
||||
const motion = DESIGN_TOKEN_MOTION_ATTRIBUTE;
|
||||
return scope === "root"
|
||||
? {
|
||||
base: ":root",
|
||||
darkExplicit: `:root[${appearance}="dark"]`,
|
||||
darkSystem: `:root:not([${appearance}="light"])`,
|
||||
reducedExplicit: `:root[${motion}="reduce"]`,
|
||||
reducedSystem: `:root:not([${motion}="allow"])`,
|
||||
}
|
||||
: {
|
||||
base: ":host",
|
||||
darkExplicit: `:host([${appearance}="dark"])`,
|
||||
darkSystem: `:host(:not([${appearance}="light"]))`,
|
||||
reducedExplicit: `:host([${motion}="reduce"])`,
|
||||
reducedSystem: `:host(:not([${motion}="allow"]))`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a declaration list and returns the immutable registry built from it.
|
||||
*
|
||||
@@ -272,10 +349,37 @@ export function createDesignTokenRegistry<const Tokens extends readonly DesignTo
|
||||
if (scope !== "root" && scope !== "host") {
|
||||
throw new DesignTokenError("DESIGN_TOKEN_SCOPE_INVALID");
|
||||
}
|
||||
const declarationText = tokens
|
||||
.map((token) => ` ${token.cssVariable}: ${token.value};`)
|
||||
.join("\n");
|
||||
return `${scope === "root" ? ":root" : ":host"} {\n${declarationText}\n}\n`;
|
||||
const selectors = selectorsFor(scope);
|
||||
const themed = tokens.some((token) => token.dark !== undefined);
|
||||
const moving = tokens.some((token) => token.reduced !== undefined);
|
||||
// `color-scheme` goes with the tokens, so native controls and scrollbars always match the
|
||||
// scheme the tokens are in — including inside an isolated root, which inherits it.
|
||||
const rules = [
|
||||
`${selectors.base} {\n${declarationBlock(
|
||||
tokens,
|
||||
(token) => token.value,
|
||||
themed ? ["color-scheme: light"] : [],
|
||||
)}\n}`,
|
||||
];
|
||||
if (themed) {
|
||||
const dark = declarationBlock(tokens, (token) => token.dark, ["color-scheme: dark"]);
|
||||
rules.push(
|
||||
`@media (prefers-color-scheme: dark) {\n${selectors.darkSystem} {\n${dark}\n}\n}`,
|
||||
`${selectors.darkExplicit} {\n${dark}\n}`,
|
||||
);
|
||||
}
|
||||
if (moving) {
|
||||
const reduced = declarationBlock(tokens, (token) => token.reduced);
|
||||
rules.push(
|
||||
`@media (prefers-reduced-motion: reduce) {\n${selectors.reducedSystem} {\n${reduced}\n}\n}`,
|
||||
`${selectors.reducedExplicit} {\n${reduced}\n}`,
|
||||
);
|
||||
}
|
||||
return `${rules.join("\n")}\n`;
|
||||
},
|
||||
valueFor: (name: string, scheme: DesignTokenColorScheme): string => {
|
||||
const token = requireToken(name);
|
||||
return scheme === "dark" ? (token.dark ?? token.value) : token.value;
|
||||
},
|
||||
variable: (name: string): string => requireToken(name).cssVariable,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type DesignTokenColorScheme,
|
||||
type EkklesiaDesignTokenName,
|
||||
ekklesiaDesignTokens,
|
||||
} from "../src/index.js";
|
||||
|
||||
/**
|
||||
* WCAG 2.2 AA contrast for every text, control, and focus pairing Ekklesia's elements actually use,
|
||||
* in both colour schemes (ADR 0060). 4.5:1 for text (§1.4.3); 3:1 for the boundary that identifies a
|
||||
* form control and for focus indication (§1.4.11). These are computed from the token values, which
|
||||
* is necessary but not sufficient: representative screens are also rendered in a real browser.
|
||||
*/
|
||||
|
||||
function channels(hex: string): readonly [number, number, number] {
|
||||
const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/u.exec(hex);
|
||||
if (!match) {
|
||||
throw new Error(`not an opaque hex colour: ${hex}`);
|
||||
}
|
||||
return [match[1], match[2], match[3]].map((part) => Number.parseInt(part ?? "0", 16) / 255) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
|
||||
function luminance(hex: string): number {
|
||||
const [r, g, b] = channels(hex).map((value) =>
|
||||
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4,
|
||||
) as [number, number, number];
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function contrastRatio(first: string, second: string): number {
|
||||
const [lighter, darker] = [luminance(first), luminance(second)].sort((a, b) => b - a) as [
|
||||
number,
|
||||
number,
|
||||
];
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
type Pairing = readonly [EkklesiaDesignTokenName, EkklesiaDesignTokenName, number, string];
|
||||
|
||||
const TEXT = 4.5;
|
||||
const NON_TEXT = 3;
|
||||
|
||||
/** Foreground, background, required ratio, and where it appears. */
|
||||
const PAIRINGS: readonly Pairing[] = [
|
||||
["text.primary", "background.page", TEXT, "body text on the page"],
|
||||
["text.primary", "background.canvas", TEXT, "sidebar and empty states"],
|
||||
["text.primary", "background.surface", TEXT, "panels, inputs"],
|
||||
["text.primary", "background.raised", TEXT, "dialogs, menus, toasts"],
|
||||
["text.primary", "background.inset", TEXT, "technical details"],
|
||||
["text.secondary", "background.page", TEXT, "descriptions on the page"],
|
||||
["text.secondary", "background.canvas", TEXT, "quiet navigation items"],
|
||||
["text.secondary", "background.surface", TEXT, "panel descriptions"],
|
||||
["text.secondary", "background.raised", TEXT, "dialog descriptions, toast bodies"],
|
||||
["text.secondary", "background.inset", TEXT, "technical detail text"],
|
||||
["text.secondary", "state.hover-background", TEXT, "hovered quiet button"],
|
||||
["text.primary", "state.pressed-background", TEXT, "pressed button"],
|
||||
["text.muted", "background.page", TEXT, "hints and metadata on the page"],
|
||||
["text.muted", "background.canvas", TEXT, "group labels in the sidebar"],
|
||||
["text.muted", "background.surface", TEXT, "field hints, placeholders"],
|
||||
["text.muted", "background.raised", TEXT, "menu captions, phrase status"],
|
||||
["accent.foreground", "accent.base", TEXT, "primary button"],
|
||||
["accent.foreground", "accent.hover", TEXT, "primary button, hovered"],
|
||||
["accent.foreground", "accent.active", TEXT, "primary button, pressed"],
|
||||
["accent.strong", "background.page", TEXT, "eyebrow and accent text"],
|
||||
["accent.strong", "background.surface", TEXT, "accent text in a panel"],
|
||||
["state.selected-foreground", "state.selected-background", TEXT, "the open page in navigation"],
|
||||
["text.danger", "status.danger-background", TEXT, "danger button and badge"],
|
||||
["text.danger", "background.surface", TEXT, "field error"],
|
||||
["text.danger", "background.canvas", TEXT, "error state title"],
|
||||
["text.danger", "background.raised", TEXT, "error in a dialog"],
|
||||
["text.warning", "status.warning-background", TEXT, "warning badge and notice"],
|
||||
["text.success", "status.success-background", TEXT, "success badge and notice"],
|
||||
["text.primary", "status.info-background", TEXT, "information notice"],
|
||||
["text.primary", "status.warning-background", TEXT, "warning notice body"],
|
||||
["text.primary", "status.danger-background", TEXT, "danger notice body"],
|
||||
["text.primary", "status.success-background", TEXT, "success notice body"],
|
||||
["text.primary", "accent.subtle", TEXT, "selected tab"],
|
||||
["border.control", "background.surface", NON_TEXT, "an input's boundary"],
|
||||
["border.control", "background.page", NON_TEXT, "an input on the page"],
|
||||
["border.control", "background.raised", NON_TEXT, "an input in a dialog"],
|
||||
["text.danger", "background.surface", NON_TEXT, "an invalid input's boundary"],
|
||||
["accent.focus", "background.page", NON_TEXT, "focus ring on the page"],
|
||||
["accent.focus", "background.canvas", NON_TEXT, "focus ring in the sidebar"],
|
||||
["accent.focus", "background.surface", NON_TEXT, "focus ring in a panel"],
|
||||
["accent.focus", "background.raised", NON_TEXT, "focus ring in a dialog"],
|
||||
];
|
||||
|
||||
describe.each([
|
||||
"light",
|
||||
"dark",
|
||||
] as const satisfies readonly DesignTokenColorScheme[])("WCAG 2.2 AA contrast, %s", (scheme) => {
|
||||
it.each(PAIRINGS)("%s on %s ≥ %s:1 (%s)", (foreground, background, required) => {
|
||||
const ratio = contrastRatio(
|
||||
ekklesiaDesignTokens.valueFor(foreground, scheme),
|
||||
ekklesiaDesignTokens.valueFor(background, scheme),
|
||||
);
|
||||
expect(Number(ratio.toFixed(2))).toBeGreaterThanOrEqual(required);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the contrast calculator", () => {
|
||||
it("agrees with WCAG's reference values", () => {
|
||||
expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 5);
|
||||
expect(contrastRatio("#ffffff", "#ffffff")).toBeCloseTo(1, 5);
|
||||
expect(contrastRatio("#767676", "#ffffff")).toBeCloseTo(4.54, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decorative placeholders stay visible in both schemes", () => {
|
||||
// A skeleton is hidden from assistive technology, so WCAG sets no ratio for it; it still has to be
|
||||
// seen against what it sits on, or it is a blank page rather than a shape of what is coming.
|
||||
it.each(["light", "dark"] as const)("%s", (scheme) => {
|
||||
for (const background of [
|
||||
"background.page",
|
||||
"background.surface",
|
||||
"background.canvas",
|
||||
] as const) {
|
||||
const ratio = contrastRatio(
|
||||
ekklesiaDesignTokens.valueFor("background.placeholder", scheme),
|
||||
ekklesiaDesignTokens.valueFor(background, scheme),
|
||||
);
|
||||
expect(ratio, background).toBeGreaterThanOrEqual(1.1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -98,11 +98,17 @@ describe("the Ekklesia design token registry", () => {
|
||||
expect(ekklesiaDesignTokens.variable("text.primary")).toBe("--ekklesia-text-primary");
|
||||
});
|
||||
|
||||
it("emits a stylesheet declaring every registered token exactly once", () => {
|
||||
it("emits a stylesheet declaring every token once in its base rule, and again only where it varies", () => {
|
||||
const stylesheet = ekklesiaDesignTokenStylesheet();
|
||||
const base = stylesheet.slice(0, stylesheet.indexOf("}"));
|
||||
|
||||
for (const token of ekklesiaDesignTokens.list()) {
|
||||
expect(stylesheet.split(`${token.cssVariable}:`).length).toBe(2);
|
||||
expect(base.split(`${token.cssVariable}:`).length, token.name).toBe(2);
|
||||
// Once more in each of the two dark rules (system and explicit), and in each of the two
|
||||
// reduced-motion rules, and nowhere else (ADR 0060).
|
||||
const expected =
|
||||
1 + (token.dark === undefined ? 0 : 2) + (token.reduced === undefined ? 0 : 2);
|
||||
expect(stylesheet.split(`${token.cssVariable}:`).length - 1, token.name).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDesignTokenRegistry,
|
||||
createEkklesiaThemeController,
|
||||
DESIGN_TOKEN_APPEARANCE_ATTRIBUTE,
|
||||
DESIGN_TOKEN_MOTION_ATTRIBUTE,
|
||||
DesignTokenError,
|
||||
EKKLESIA_DARK_SCHEME_QUERY,
|
||||
EKKLESIA_REDUCED_MOTION_QUERY,
|
||||
type EkklesiaMediaQueryList,
|
||||
ekklesiaDesignTokens,
|
||||
ekklesiaDesignTokenStylesheet,
|
||||
parseEkklesiaAppearance,
|
||||
parseEkklesiaMotionPreference,
|
||||
resolveEkklesiaColorScheme,
|
||||
resolveEkklesiaReducedMotion,
|
||||
} from "../src/index.js";
|
||||
|
||||
/** A media query a test can flip, as a running OS would. */
|
||||
function mediaQuery(initial = false) {
|
||||
const listeners = new Set<() => void>();
|
||||
const query: EkklesiaMediaQueryList & { set(matches: boolean): void } = {
|
||||
addEventListener: (_type, listener) => listeners.add(listener),
|
||||
matches: initial,
|
||||
removeEventListener: (_type, listener) => listeners.delete(listener),
|
||||
set(matches) {
|
||||
(query as { matches: boolean }).matches = matches;
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
},
|
||||
};
|
||||
return { listeners, query };
|
||||
}
|
||||
|
||||
function environment(options: { dark?: boolean; reduced?: boolean } = {}) {
|
||||
const dark = mediaQuery(options.dark);
|
||||
const reduced = mediaQuery(options.reduced);
|
||||
const attributes = new Map<string, string>();
|
||||
const target = {
|
||||
setAttribute: vi.fn((name: string, value: string) => attributes.set(name, value)),
|
||||
};
|
||||
const matchMedia = (query: string) => {
|
||||
if (query === EKKLESIA_DARK_SCHEME_QUERY) {
|
||||
return dark.query;
|
||||
}
|
||||
if (query === EKKLESIA_REDUCED_MOTION_QUERY) {
|
||||
return reduced.query;
|
||||
}
|
||||
throw new Error(`unexpected query ${query}`);
|
||||
};
|
||||
return { attributes, dark, matchMedia, reduced, target };
|
||||
}
|
||||
|
||||
const COLOUR_CATEGORIES = new Set(["background", "text", "accent", "status", "state"]);
|
||||
const isColour = (value: string) => /^(?:#[0-9a-f]{6}|rgba\()/u.test(value);
|
||||
|
||||
describe("light and dark token sets", () => {
|
||||
it("gives every colour and shadow a dark value, so no semantic token is missing in either theme", () => {
|
||||
const colours = ekklesiaDesignTokens
|
||||
.list()
|
||||
.filter((token) => isColour(token.value) || token.category === "shadow");
|
||||
expect(colours.length).toBeGreaterThan(35);
|
||||
for (const token of colours) {
|
||||
if (token.name === "shadow.none") {
|
||||
continue;
|
||||
}
|
||||
expect(token.dark, token.name).toBeDefined();
|
||||
expect(token.dark, token.name).not.toBe(token.value);
|
||||
}
|
||||
});
|
||||
|
||||
it("varies only colour, shadow, and border-colour tokens by scheme", () => {
|
||||
for (const token of ekklesiaDesignTokens.list()) {
|
||||
if (token.dark === undefined) {
|
||||
continue;
|
||||
}
|
||||
expect(
|
||||
COLOUR_CATEGORIES.has(token.category) ||
|
||||
token.category === "shadow" ||
|
||||
(token.category === "border" && isColour(token.value)),
|
||||
token.name,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a designed dark theme, not an inversion: no pure black or pure white surfaces or text", () => {
|
||||
for (const name of [
|
||||
"background.page",
|
||||
"background.canvas",
|
||||
"background.surface",
|
||||
"background.raised",
|
||||
"text.primary",
|
||||
] as const) {
|
||||
expect(ekklesiaDesignTokens.valueFor(name, "dark"), name).not.toMatch(
|
||||
/^#(?:000000|ffffff)$/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("answers a token's value per scheme, falling back to its single value", () => {
|
||||
expect(ekklesiaDesignTokens.valueFor("background.page", "light")).toBe("#f3f7f6");
|
||||
expect(ekklesiaDesignTokens.valueFor("background.page", "dark")).toBe("#0e1615");
|
||||
expect(ekklesiaDesignTokens.valueFor("spacing.md", "dark")).toBe("0.75rem");
|
||||
expect(() => ekklesiaDesignTokens.valueFor("nope" as "spacing.md", "dark")).toThrow(
|
||||
DesignTokenError,
|
||||
);
|
||||
});
|
||||
|
||||
it("validates a dark or reduced value exactly like a default value", () => {
|
||||
for (const hostile of ["red; color: blue", "url(x)", "#fff !important", "a{b}"]) {
|
||||
expect(() =>
|
||||
createDesignTokenRegistry([
|
||||
{ dark: hostile, description: "d", label: "l", name: "text.x", value: "#fff" },
|
||||
]),
|
||||
).toThrow(DesignTokenError);
|
||||
expect(() =>
|
||||
createDesignTokenRegistry([
|
||||
{ description: "d", label: "l", name: "motion.x", reduced: hostile, value: "1s" },
|
||||
]),
|
||||
).toThrow(DesignTokenError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the theme stylesheet resolves appearance and motion in CSS", () => {
|
||||
const stylesheet = ekklesiaDesignTokenStylesheet();
|
||||
|
||||
it("is deterministic", () => {
|
||||
expect(ekklesiaDesignTokenStylesheet()).toBe(stylesheet);
|
||||
});
|
||||
|
||||
it("is light by default and says so to the browser", () => {
|
||||
expect(stylesheet.startsWith(":root {\n color-scheme: light;\n")).toBe(true);
|
||||
expect(stylesheet).toContain("--ekklesia-background-page: #f3f7f6;");
|
||||
});
|
||||
|
||||
it("follows the system into dark unless light was chosen, and goes dark when dark was chosen", () => {
|
||||
expect(stylesheet).toContain(
|
||||
`@media (prefers-color-scheme: dark) {\n:root:not([${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="light"]) {\n color-scheme: dark;\n`,
|
||||
);
|
||||
expect(stylesheet).toContain(
|
||||
`:root[${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="dark"] {\n color-scheme: dark;\n`,
|
||||
);
|
||||
const darkRule = stylesheet.slice(
|
||||
stylesheet.indexOf(`:root[${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="dark"]`),
|
||||
);
|
||||
expect(darkRule).toContain("--ekklesia-background-page: #0e1615;");
|
||||
});
|
||||
|
||||
it("zeroes every motion duration when the system asks, unless motion was allowed, and when reduce was chosen", () => {
|
||||
expect(stylesheet).toContain(
|
||||
`@media (prefers-reduced-motion: reduce) {\n:root:not([${DESIGN_TOKEN_MOTION_ATTRIBUTE}="allow"]) {\n --ekklesia-motion-duration-short: 0ms;\n --ekklesia-motion-duration-ambient: 0s;\n}\n}`,
|
||||
);
|
||||
expect(stylesheet).toContain(
|
||||
`:root[${DESIGN_TOKEN_MOTION_ATTRIBUTE}="reduce"] {\n --ekklesia-motion-duration-short: 0ms;\n --ekklesia-motion-duration-ambient: 0s;\n}`,
|
||||
);
|
||||
for (const token of ekklesiaDesignTokens.listByCategory("motion")) {
|
||||
expect(token.reduced, token.name).toMatch(/^0m?s$/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the same rules on an isolated root's host element", () => {
|
||||
const host = ekklesiaDesignTokenStylesheet("host");
|
||||
expect(host.startsWith(":host {")).toBe(true);
|
||||
expect(host).toContain(`:host(:not([${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="light"]))`);
|
||||
expect(host).toContain(`:host([${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="dark"])`);
|
||||
expect(host).toContain(`:host([${DESIGN_TOKEN_MOTION_ATTRIBUTE}="reduce"])`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("radius", () => {
|
||||
it("scales the small, medium, and large radii from one multiplier, keeping today's values", () => {
|
||||
expect(ekklesiaDesignTokens.get("radius.scale")?.value).toBe("1");
|
||||
expect(ekklesiaDesignTokens.get("radius.sm")?.value).toBe(
|
||||
"calc(0.25rem * var(--ekklesia-radius-scale))",
|
||||
);
|
||||
expect(ekklesiaDesignTokens.get("radius.md")?.value).toBe(
|
||||
"calc(0.625rem * var(--ekklesia-radius-scale))",
|
||||
);
|
||||
expect(ekklesiaDesignTokens.get("radius.lg")?.value).toBe(
|
||||
"calc(0.875rem * var(--ekklesia-radius-scale))",
|
||||
);
|
||||
expect(ekklesiaDesignTokens.get("radius.pill")?.value).toBe("9999px");
|
||||
expect(ekklesiaDesignTokens.get("radius.none")?.value).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("appearance and motion preferences", () => {
|
||||
it("default to system, and read untrusted values safely", () => {
|
||||
expect(parseEkklesiaAppearance(undefined)).toBe("system");
|
||||
expect(parseEkklesiaAppearance("dark")).toBe("dark");
|
||||
expect(parseEkklesiaAppearance("DARK")).toBe("system");
|
||||
expect(parseEkklesiaAppearance({})).toBe("system");
|
||||
expect(parseEkklesiaMotionPreference("allow")).toBe("allow");
|
||||
expect(parseEkklesiaMotionPreference("off")).toBe("system");
|
||||
});
|
||||
|
||||
it("resolve to what is on screen", () => {
|
||||
expect(resolveEkklesiaColorScheme("system", false)).toBe("light");
|
||||
expect(resolveEkklesiaColorScheme("system", true)).toBe("dark");
|
||||
expect(resolveEkklesiaColorScheme("light", true)).toBe("light");
|
||||
expect(resolveEkklesiaColorScheme("dark", false)).toBe("dark");
|
||||
expect(resolveEkklesiaReducedMotion("system", true)).toBe(true);
|
||||
expect(resolveEkklesiaReducedMotion("system", false)).toBe(false);
|
||||
expect(resolveEkklesiaReducedMotion("reduce", false)).toBe(true);
|
||||
expect(resolveEkklesiaReducedMotion("allow", true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the theme controller", () => {
|
||||
it("defaults to System and applies its attributes at once, before anything renders", () => {
|
||||
const env = environment();
|
||||
const controller = createEkklesiaThemeController({
|
||||
matchMedia: env.matchMedia,
|
||||
target: env.target,
|
||||
});
|
||||
|
||||
expect(controller.appearance()).toBe("system");
|
||||
expect(controller.motion()).toBe("system");
|
||||
expect(env.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system");
|
||||
expect(env.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("system");
|
||||
});
|
||||
|
||||
it("resolves System to Light or Dark from the system, and follows it live", () => {
|
||||
const env = environment({ dark: false });
|
||||
const controller = createEkklesiaThemeController({
|
||||
matchMedia: env.matchMedia,
|
||||
target: env.target,
|
||||
});
|
||||
const listener = vi.fn();
|
||||
controller.subscribe(listener);
|
||||
expect(controller.colorScheme()).toBe("light");
|
||||
|
||||
env.dark.query.set(true);
|
||||
|
||||
expect(controller.colorScheme()).toBe("dark");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lets an explicit Light or Dark ignore the system", () => {
|
||||
for (const appearance of ["light", "dark"] as const) {
|
||||
const env = environment({ dark: appearance === "light" });
|
||||
const controller = createEkklesiaThemeController({
|
||||
appearance,
|
||||
matchMedia: env.matchMedia,
|
||||
target: env.target,
|
||||
});
|
||||
expect(controller.colorScheme(), appearance).toBe(appearance);
|
||||
env.dark.query.set(appearance !== "light");
|
||||
expect(controller.colorScheme(), appearance).toBe(appearance);
|
||||
expect(env.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(appearance);
|
||||
}
|
||||
});
|
||||
|
||||
it("switches mode in place, without a reload, and only notifies on a real change", () => {
|
||||
const env = environment({ dark: true });
|
||||
const controller = createEkklesiaThemeController({
|
||||
matchMedia: env.matchMedia,
|
||||
target: env.target,
|
||||
});
|
||||
const listener = vi.fn();
|
||||
controller.subscribe(listener);
|
||||
|
||||
controller.setAppearance("light");
|
||||
expect(env.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("light");
|
||||
expect(controller.colorScheme()).toBe("light");
|
||||
controller.setAppearance("light");
|
||||
controller.setAppearance("dark");
|
||||
controller.setAppearance("system");
|
||||
expect(controller.colorScheme()).toBe("dark");
|
||||
expect(listener).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("follows the system's reduced-motion setting, and supports an explicit Reduce or Allow", () => {
|
||||
const env = environment({ reduced: false });
|
||||
const controller = createEkklesiaThemeController({
|
||||
matchMedia: env.matchMedia,
|
||||
target: env.target,
|
||||
});
|
||||
expect(controller.reducedMotion()).toBe(false);
|
||||
env.reduced.query.set(true);
|
||||
expect(controller.reducedMotion()).toBe(true);
|
||||
|
||||
controller.setMotion("allow");
|
||||
expect(env.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("allow");
|
||||
expect(controller.reducedMotion()).toBe(false);
|
||||
controller.setMotion("reduce");
|
||||
env.reduced.query.set(false);
|
||||
expect(controller.reducedMotion()).toBe(true);
|
||||
});
|
||||
|
||||
it("takes an untrusted stored preference straight in", () => {
|
||||
const env = environment();
|
||||
const controller = createEkklesiaThemeController({
|
||||
appearance: "purple" as "dark",
|
||||
matchMedia: env.matchMedia,
|
||||
motion: 7 as unknown as "reduce",
|
||||
target: env.target,
|
||||
});
|
||||
expect(controller.appearance()).toBe("system");
|
||||
expect(controller.motion()).toBe("system");
|
||||
});
|
||||
|
||||
it("listens to the system only while someone is subscribed", () => {
|
||||
const env = environment();
|
||||
const controller = createEkklesiaThemeController({
|
||||
matchMedia: env.matchMedia,
|
||||
target: env.target,
|
||||
});
|
||||
const unsubscribe = controller.subscribe(() => undefined);
|
||||
expect(env.dark.listeners.size).toBe(1);
|
||||
expect(env.reduced.listeners.size).toBe(1);
|
||||
unsubscribe();
|
||||
expect(env.dark.listeners.size).toBe(0);
|
||||
expect(env.reduced.listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it("works with no media queries at all, as light with full motion", () => {
|
||||
const env = environment();
|
||||
const controller = createEkklesiaThemeController({ target: env.target });
|
||||
expect(controller.colorScheme()).toBe("light");
|
||||
expect(controller.reducedMotion()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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-control 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>
|
||||
);
|
||||
}
|
||||
+167
-14
@@ -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,21 +90,23 @@ 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__content">
|
||||
<div className="ek-toast__title">
|
||||
<span aria-hidden="true">{statusSymbols[tone]} </span>
|
||||
{props.title}
|
||||
@@ -102,6 +115,17 @@ export function Toast(props: ToastProps): ReactElement {
|
||||
<div className="ek-toast__body">{props.children}</div>
|
||||
)}
|
||||
</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 === 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";
|
||||
|
||||
+171
-5
@@ -162,7 +162,7 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
|
||||
.ek-control {
|
||||
background: ${designToken("background.surface")};
|
||||
border: ${designToken("border.width")} solid ${designToken("border.base")};
|
||||
border: ${designToken("border.width")} solid ${designToken("border.control")};
|
||||
border-radius: ${designToken("radius.md")};
|
||||
box-sizing: border-box;
|
||||
color: ${designToken("text.primary")};
|
||||
@@ -587,7 +587,8 @@ export const EKKLESIA_UI_STYLESHEET = `
|
||||
}
|
||||
|
||||
.ek-spinner {
|
||||
animation: ek-spin 1s linear infinite;
|
||||
/* Spins at 0.625 of the ambient cycle (1s by default); not at all when motion is reduced. */
|
||||
animation: ek-spin calc(${designToken("motion.duration-ambient")} * 0.625) linear infinite;
|
||||
border: ${designToken("state.focus-visible-width")} solid ${designToken("border.base")};
|
||||
border-block-start-color: ${designToken("accent.base")};
|
||||
border-radius: ${designToken("radius.pill")};
|
||||
@@ -595,17 +596,182 @@ 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 ${designToken("motion.duration-short")} 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 ${designToken("motion.duration-ambient")} ease-in-out infinite;
|
||||
background: ${designToken("background.placeholder")};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ek-spinner {
|
||||
animation: none;
|
||||
@keyframes ek-skeleton-pulse {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ek-toast-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Every animation above takes its duration from a motion token, which is zero when motion is
|
||||
reduced — by the system, or by a person's own preference (ADR 0060) — so none of them needs a
|
||||
reduced-motion rule of its own, and nothing here depends on movement to be understood. */
|
||||
`;
|
||||
|
||||
/** Marks a stylesheet this package installed, so installation is idempotent per root. */
|
||||
|
||||
+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,798 @@
|
||||
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, through the shared motion tokens", () => {
|
||||
// The pulse lasts one ambient cycle, which the token stylesheet zeroes when motion is reduced —
|
||||
// by the system or by an explicit preference (ADR 0060) — so the element needs no rule of its own.
|
||||
expect(EKKLESIA_UI_STYLESHEET).toMatch(
|
||||
/\.ek-skeleton \{[^}]*animation: ek-skeleton-pulse var\(--ekklesia-motion-duration-ambient\)/su,
|
||||
);
|
||||
expect(EKKLESIA_UI_STYLESHEET).not.toContain("prefers-reduced-motion");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -91,3 +91,31 @@ describe("the shared elements consume design tokens, never hardcoded theme value
|
||||
expect(document.querySelectorAll(`style[${EKKLESIA_UI_STYLE_ATTRIBUTE}]`).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the element stylesheet takes every colour, radius, and duration from a token (ADR 0060)", () => {
|
||||
// Comments are dropped: they may mention a value without using it.
|
||||
const css = EKKLESIA_UI_STYLESHEET.replace(/\/\*[\s\S]*?\*\//gu, "");
|
||||
|
||||
it("contains no raw colour", () => {
|
||||
expect(css).not.toMatch(/#[0-9a-f]{3,8}\b/iu);
|
||||
expect(css).not.toMatch(/\b(?:rgba?|hsla?|oklch|lab|lch|hwb)\(/iu);
|
||||
// A named colour is a raw colour too; `transparent` and `currentColor` are not colours.
|
||||
expect(css).not.toMatch(/:\s*(?:white|black|red|green|blue|gray|grey)\s*;/iu);
|
||||
});
|
||||
|
||||
it("rounds corners only with radius tokens", () => {
|
||||
for (const match of css.matchAll(/border-radius:\s*([^;]+);/gu)) {
|
||||
// Each corner is a radius token, or square.
|
||||
for (const corner of (match[1] ?? "").split(/\s+/u)) {
|
||||
expect(corner, match[0]).toMatch(/^(?:var\(--ekklesia-radius-[a-z]+\)|0)$/u);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("animates only for motion-token durations", () => {
|
||||
for (const match of css.matchAll(/(?:animation|transition):\s*([^;]+);/gu)) {
|
||||
expect(match[1], match[0]).not.toMatch(/\b\d+(?:\.\d+)?m?s\b/u);
|
||||
expect(match[1], match[0]).toContain("--ekklesia-motion-duration-");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+15
@@ -55,6 +55,9 @@ importers:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.3
|
||||
version: 2.5.3
|
||||
'@ekklesia/design-tokens':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/design-tokens
|
||||
'@ekklesia/module-ui-host':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/module-ui-host
|
||||
@@ -407,6 +410,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 +2407,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 +4380,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
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -102,10 +102,27 @@ const send = (method, params) => cdp.send(method, params, sessionId);
|
||||
* `loadingFailed` here, never a response. */
|
||||
const responses = [];
|
||||
const failed = [];
|
||||
/** Requests the page has started and not finished, by id: what a timed-out wait was stuck behind.
|
||||
* Only paths are ever printed, never a query, header, or body. */
|
||||
const inflight = new Map();
|
||||
/** Every request's URL by id, so a failure can be named after the request it ended. */
|
||||
const inflightUrls = new Map();
|
||||
cdp.on((message) => {
|
||||
if (message.sessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
if (message.method === "Network.requestWillBeSent") {
|
||||
inflightUrls.set(message.params.requestId, message.params.request.url);
|
||||
inflight.set(message.params.requestId, {
|
||||
type: message.params.type,
|
||||
url: message.params.request.url,
|
||||
});
|
||||
} else if (
|
||||
message.method === "Network.loadingFinished" ||
|
||||
message.method === "Network.loadingFailed"
|
||||
) {
|
||||
inflight.delete(message.params.requestId);
|
||||
}
|
||||
if (message.method === "Network.responseReceived") {
|
||||
const { response, type } = message.params;
|
||||
responses.push({ status: response.status, type, url: response.url });
|
||||
@@ -145,6 +162,37 @@ async function waitFor(expression, description) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
console.log(`FAIL timed out waiting for ${description}`);
|
||||
const page = await evaluate(
|
||||
`({ path: location.pathname, ready: document.readyState, shell: Boolean(document.querySelector('[data-testid="host-shell"]')), loading: Boolean(document.querySelector('[data-testid="host-loading"]')), error: Boolean(document.querySelector('[data-testid="host-error"], [data-testid="module-error"]')) })`,
|
||||
).catch(() => "unavailable");
|
||||
const pending = [...inflight.values()].map((request) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
return `${request.type} ${url.origin === apiOrigin ? "api" : url.origin === dashboardOrigin ? "dashboard" : "other"}${url.pathname}`;
|
||||
} catch {
|
||||
return request.type;
|
||||
}
|
||||
});
|
||||
const shortPath = (value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return `${url.origin === apiOrigin ? "api" : url.origin === dashboardOrigin ? "dashboard" : "other"}${url.pathname}`;
|
||||
} catch {
|
||||
return "unparseable";
|
||||
}
|
||||
};
|
||||
// The last few refused or failed requests: which one broke the page, and how.
|
||||
const refusedRecently = responses
|
||||
.filter((response) => response.status >= 400)
|
||||
.slice(-6)
|
||||
.map((response) => `${response.status} ${shortPath(response.url)}`);
|
||||
const failedRecently = failed
|
||||
.slice(-6)
|
||||
.map(
|
||||
(failure) =>
|
||||
`${failure.errorText}${failure.corsErrorStatus ? ` cors:${failure.corsErrorStatus.corsError}` : ""} ${shortPath(inflightUrls.get(failure.requestId) ?? "")}`,
|
||||
);
|
||||
console.log(`info ${JSON.stringify({ failedRecently, page, pending, refusedRecently })}`);
|
||||
failures += 1;
|
||||
return false;
|
||||
}
|
||||
@@ -187,10 +235,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 +276,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 +332,350 @@ 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",
|
||||
);
|
||||
|
||||
// --- Settings (ADR 0061): a deep link, a refresh, Discover, a module's settings, the drawer -------
|
||||
await navigate(`${dashboardOrigin}/settings`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("settings-home")} && ${selector("select-settings")}?.getAttribute("aria-current") === "page"`,
|
||||
"the Settings landing page from its own address",
|
||||
),
|
||||
"a deep link to /settings opens the Settings landing page",
|
||||
);
|
||||
await evaluate(`${selector("select-settings-displays")}.click()`);
|
||||
await waitFor(selector("settings-section-displays"), "Settings → Displays");
|
||||
await navigate(`${dashboardOrigin}/settings/displays`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === "/settings/displays" && ${selector("settings-section-displays")} && ${selector("select-settings-displays")}?.getAttribute("aria-current") === "page"`,
|
||||
"Settings → Displays after a refresh",
|
||||
),
|
||||
"refreshing a Settings page stays on it, marked in the navigation",
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/settings/discover`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === "/settings/discover/browse" && ${selector("settings-discover-browse")}`,
|
||||
"Discover opening at Browse",
|
||||
),
|
||||
"entering Discover opens Browse at its one canonical address",
|
||||
);
|
||||
if (openedModuleId) {
|
||||
await navigate(`${dashboardOrigin}/settings/${openedModuleId}`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("module-settings")} && ${selector(`select-module-settings-${openedModuleId}`)}?.getAttribute("aria-current") === "page"`,
|
||||
"the module's settings page",
|
||||
),
|
||||
`/settings/${openedModuleId} opens the module's settings`,
|
||||
);
|
||||
}
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 2,
|
||||
height: 844,
|
||||
mobile: true,
|
||||
width: 390,
|
||||
});
|
||||
await navigate(`${dashboardOrigin}/settings`);
|
||||
await waitFor(selector("mobile-navigation-trigger"), "the phone navigation trigger in Settings");
|
||||
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
|
||||
await waitFor(
|
||||
`${selector("navigation-drawer")}?.querySelector('[data-testid="select-settings-system"]')`,
|
||||
"Settings in the phone drawer",
|
||||
);
|
||||
await evaluate(`${selector("select-settings-system")}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === "/settings/system" && !${selector("navigation-drawer")} && ${selector("settings-section-system")}`,
|
||||
"Settings → System chosen from the drawer",
|
||||
),
|
||||
"the phone drawer offers the same Settings hierarchy and closes once a page is chosen",
|
||||
);
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 1,
|
||||
height: 1080,
|
||||
mobile: false,
|
||||
width: 1920,
|
||||
});
|
||||
await waitFor(selector("select-overview"), "the sidebar after widening the window");
|
||||
await evaluate(`${selector("select-overview")}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === "/" && ${selector("host-overview")} && !${selector("settings-subnavigation")}`,
|
||||
"the Overview from Settings",
|
||||
),
|
||||
"leaving Settings returns to the normal Dashboard navigation",
|
||||
);
|
||||
|
||||
// --- Account and Appearance (ADR 0062): identity, live switching, persistence, first paint ------
|
||||
const emulate = (colorScheme, reducedMotion) =>
|
||||
send("Emulation.setEmulatedMedia", {
|
||||
features: [
|
||||
{ name: "prefers-color-scheme", value: colorScheme },
|
||||
{ name: "prefers-reduced-motion", value: reducedMotion },
|
||||
],
|
||||
});
|
||||
const rootScheme = "getComputedStyle(document.documentElement).colorScheme";
|
||||
const rootToken = (name) =>
|
||||
`getComputedStyle(document.documentElement).getPropertyValue("--ekklesia-${name}").trim()`;
|
||||
const option = (group, value) =>
|
||||
`document.querySelector('[data-testid="appearance-${group}-${value}"] input')`;
|
||||
const savedPreferences = () =>
|
||||
responses.filter(
|
||||
(response) =>
|
||||
response.url === `${apiOrigin}/api/v1/account/preferences` && response.status === 200,
|
||||
).length;
|
||||
/**
|
||||
* Chooses one option on the Appearance page and waits until the account has saved it. An option
|
||||
* already chosen needs no save: a radio that is already checked does not change.
|
||||
*/
|
||||
async function choose(group, value) {
|
||||
if (await evaluate(`Boolean(${option(group, value)}?.checked)`)) {
|
||||
return true;
|
||||
}
|
||||
const before = savedPreferences();
|
||||
await evaluate(`${option(group, value)}.click()`);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (savedPreferences() <= before && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return (
|
||||
savedPreferences() > before &&
|
||||
(await waitFor(
|
||||
`${option(group, value)}?.checked && ${selector("appearance-status")}?.textContent.includes("Saved")`,
|
||||
`${group} ${value} to be saved`,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
await emulate("light", "no-preference");
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
await waitFor(selector("host-account-menu"), "the account control");
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("host-account-menu")}.textContent.includes(${JSON.stringify(required("SMOKE_OWNER_EMAIL"))}) && ${selector("host-account-menu")}.querySelector('[data-testid="account-avatar"]')`,
|
||||
"the account control to name the signed-in person",
|
||||
),
|
||||
"the account control shows who is signed in, with their initials",
|
||||
);
|
||||
await evaluate(`${selector("host-account-menu")}.click()`);
|
||||
await waitFor(selector("host-appearance-link"), "Appearance in the account menu");
|
||||
await evaluate(`${selector("host-appearance-link")}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === "/account/appearance" && ${selector("account-appearance")}`,
|
||||
"the Appearance page",
|
||||
),
|
||||
"Appearance opens from the account menu at /account/appearance",
|
||||
);
|
||||
// Start from System for both, whatever an earlier pass left on this account.
|
||||
check(
|
||||
(await choose("appearance", "system")) && (await choose("motion", "system")),
|
||||
"System appearance and System motion save to the account",
|
||||
);
|
||||
const lightBackground = await evaluate(`getComputedStyle(document.body).backgroundColor`);
|
||||
await emulate("dark", "no-preference");
|
||||
check(
|
||||
await waitFor(`${rootScheme} === "dark"`, "System to follow a dark operating system"),
|
||||
"with System chosen, an operating system switching to dark restyles the page live",
|
||||
);
|
||||
await emulate("light", "no-preference");
|
||||
check(
|
||||
await waitFor(`${rootScheme} === "light"`, "System to follow a light operating system"),
|
||||
"and switching back to light restyles it again, with no reload",
|
||||
);
|
||||
check(await choose("appearance", "dark"), "choosing Dark saves it to the account");
|
||||
check(
|
||||
await waitFor(
|
||||
`${rootScheme} === "dark" && getComputedStyle(document.body).backgroundColor !== ${JSON.stringify(lightBackground)}`,
|
||||
"Dark to take effect under a light operating system",
|
||||
),
|
||||
"Dark takes effect at once, overriding a light operating system",
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/account/appearance`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("account-appearance")} && ${option("appearance", "dark")}?.checked && ${rootScheme} === "dark" && localStorage.getItem("ekklesia.dashboard.appearance") === "dark system"`,
|
||||
"Dark after a reload",
|
||||
),
|
||||
"a reload keeps Dark: the account holds it, and this browser cached two words for first paint",
|
||||
);
|
||||
// First paint without the application: only the pre-paint script runs, and it already applies Dark.
|
||||
await send("Network.setBlockedURLs", { urls: ["*/assets/ekklesia-dashboard/app.js"] });
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
check(
|
||||
await waitFor(
|
||||
`document.readyState === "complete" && !${selector("host-shell")} && document.documentElement.getAttribute("data-ekklesia-appearance") === "dark" && document.querySelector('meta[name="color-scheme"]').content === "dark"`,
|
||||
"the pre-paint script alone to apply Dark",
|
||||
),
|
||||
"before the application script runs at all, the pre-paint script has applied Dark",
|
||||
);
|
||||
await send("Network.setBlockedURLs", { urls: [] });
|
||||
if (openedModuleId) {
|
||||
const moduleScheme = `(() => {
|
||||
const host = document.querySelector("[data-ekklesia-module-ui]");
|
||||
const inner = host?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]");
|
||||
if (!inner) return null;
|
||||
const style = getComputedStyle(inner);
|
||||
return { page: style.getPropertyValue("--ekklesia-background-page").trim(), scheme: style.colorScheme };
|
||||
})()`;
|
||||
await navigate(`${dashboardOrigin}/${openedModuleId}`);
|
||||
await waitFor(
|
||||
`${selector("module-container")}?.childElementCount > 0 && (${moduleScheme})?.scheme === "dark"`,
|
||||
"the module's isolated root in Dark",
|
||||
);
|
||||
const darkModule = await evaluate(moduleScheme);
|
||||
check(
|
||||
darkModule?.scheme === "dark" &&
|
||||
darkModule.page === (await evaluate(rootToken("background-page"))),
|
||||
`the ${openedModuleId} module's Shadow DOM root follows Dark through the tokens`,
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/account/appearance`);
|
||||
await waitFor(selector("account-appearance"), "the Appearance page");
|
||||
await emulate("dark", "no-preference");
|
||||
check(await choose("appearance", "light"), "choosing Light saves it to the account");
|
||||
check(
|
||||
await waitFor(`${rootScheme} === "light"`, "Light under a dark operating system"),
|
||||
"Light takes effect at once, overriding a dark operating system",
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/${openedModuleId}`);
|
||||
await waitFor(
|
||||
`${selector("module-container")}?.childElementCount > 0 && (${moduleScheme})?.scheme === "light"`,
|
||||
"the module's isolated root in Light",
|
||||
);
|
||||
const lightModule = await evaluate(moduleScheme);
|
||||
check(
|
||||
lightModule?.scheme === "light" && lightModule.page !== darkModule?.page,
|
||||
`the ${openedModuleId} module's Shadow DOM root follows Light too`,
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/account/appearance`);
|
||||
await waitFor(selector("account-appearance"), "the Appearance page");
|
||||
}
|
||||
// Reduced motion: System follows the operating system; Allow and Reduce override it.
|
||||
const shortDuration = rootToken("motion-duration-short");
|
||||
await emulate("light", "reduce");
|
||||
check(
|
||||
await waitFor(`${shortDuration} === "0ms"`, "System motion to follow a reduce request"),
|
||||
"with System motion, an operating system asking for less motion zeroes durations",
|
||||
);
|
||||
check(await choose("motion", "allow"), "choosing Allow saves it to the account");
|
||||
check(
|
||||
await waitFor(`${shortDuration} !== "0ms"`, "Allow under a reduce request"),
|
||||
"Allow keeps the platform's subtle motion even when the system asks for less",
|
||||
);
|
||||
await emulate("light", "no-preference");
|
||||
check(await choose("motion", "reduce"), "choosing Reduce saves it to the account");
|
||||
check(
|
||||
await waitFor(`${shortDuration} === "0ms"`, "Reduce with no system request"),
|
||||
"Reduce zeroes motion even when the system asks for none",
|
||||
);
|
||||
if (displayPath !== null) {
|
||||
// The display host is a lobby screen, not a person: it stays Light whatever the OS or the account.
|
||||
check(await choose("appearance", "dark"), "Dark is chosen again before visiting a display");
|
||||
await emulate("dark", "no-preference");
|
||||
await navigate(`${apiOrigin}${displayPath}`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("display-module-container")}?.childElementCount > 0 && ${rootScheme} === "light"`,
|
||||
"the display to stay Light",
|
||||
),
|
||||
"the display page stays Light under a dark operating system and a Dark account choice",
|
||||
);
|
||||
await emulate("light", "no-preference");
|
||||
}
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 2,
|
||||
height: 844,
|
||||
mobile: true,
|
||||
width: 390,
|
||||
});
|
||||
await navigate(`${dashboardOrigin}/account/appearance`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("account-appearance")} && document.documentElement.scrollWidth <= window.innerWidth && [...document.querySelectorAll(".ek-shell-choice")].length === 6 && [...document.querySelectorAll(".ek-shell-choice")].every((element) => element.getBoundingClientRect().height >= 44)`,
|
||||
"the Appearance page at phone width",
|
||||
),
|
||||
"at phone width the Appearance page fits without sideways scrolling, every option a 44px+ target",
|
||||
);
|
||||
check(
|
||||
(await choose("appearance", "system")) && (await choose("motion", "system")),
|
||||
"the account is put back to System for both",
|
||||
);
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 1,
|
||||
height: 1080,
|
||||
mobile: false,
|
||||
width: 1920,
|
||||
});
|
||||
await send("Emulation.setEmulatedMedia", { features: [] });
|
||||
|
||||
// --- 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"),
|
||||
@@ -278,6 +685,12 @@ const principal = await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/pr
|
||||
credentials: "include",
|
||||
}).then((response) => response.json()).then((body) => body.kind)`);
|
||||
check(principal === "anonymous", "after sign-out, the API answers anonymous to the same browser");
|
||||
check(
|
||||
await evaluate(
|
||||
`localStorage.getItem("ekklesia.dashboard.appearance") === null && document.documentElement.getAttribute("data-ekklesia-appearance") === "system"`,
|
||||
),
|
||||
"sign-out forgets this browser's cached appearance, and the page follows the system",
|
||||
);
|
||||
|
||||
await cdp.send("Target.closeTarget", { targetId });
|
||||
cdp.close();
|
||||
|
||||
@@ -73,8 +73,9 @@ async function edgeChecks() {
|
||||
check(
|
||||
page.headers.get("content-type") === "text/html; charset=utf-8" &&
|
||||
html.includes('id="ekklesia-host"') &&
|
||||
html.includes('src="/assets/ekklesia-dashboard/app.js"'),
|
||||
"GET / is the Dashboard HTML",
|
||||
html.includes('src="/assets/ekklesia-dashboard/app.js"') &&
|
||||
html.includes('<script src="/assets/ekklesia-dashboard/appearance.js"></script>'),
|
||||
"GET / is the Dashboard HTML, with its pre-paint appearance script (ADR 0062)",
|
||||
);
|
||||
check(
|
||||
page.headers.get("x-frame-options") === "DENY" &&
|
||||
@@ -88,10 +89,47 @@ 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",
|
||||
"/account/appearance",
|
||||
]) {
|
||||
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");
|
||||
|
||||
const appearanceScript = await fetch(
|
||||
`${dashboardOrigin}/assets/ekklesia-dashboard/appearance.js`,
|
||||
);
|
||||
const appearanceSource = await appearanceScript.text();
|
||||
check(
|
||||
appearanceScript.status === 200 &&
|
||||
appearanceScript.headers.get("content-type") === "text/javascript; charset=utf-8" &&
|
||||
appearanceSource.includes("ekklesia.dashboard.appearance"),
|
||||
"GET /assets/ekklesia-dashboard/appearance.js is the pre-paint script",
|
||||
);
|
||||
|
||||
const outside = await fetch(`${dashboardOrigin}/favicon.ico`);
|
||||
check(
|
||||
outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"),
|
||||
@@ -152,10 +190,18 @@ 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",
|
||||
);
|
||||
check(
|
||||
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/appearance.js`),
|
||||
"the API hostname refuses the Dashboard's pre-paint script",
|
||||
);
|
||||
const display = await fetch(`${apiOrigin}/display/does-not-exist`);
|
||||
check(
|
||||
display.status === 404 && (await display.text()) === '{"code":"NOT_FOUND"}',
|
||||
@@ -435,6 +481,38 @@ async function sessionChecks() {
|
||||
"the session resolves an authenticated principal through the proxy",
|
||||
);
|
||||
|
||||
// The person's own account (ADR 0062): who they are, and a preference that round-trips.
|
||||
const account = await fetch(`${apiOrigin}/api/v1/account`, {
|
||||
headers: { cookie, origin: dashboardOrigin },
|
||||
});
|
||||
const accountBody = await account.json();
|
||||
check(
|
||||
account.status === 200 &&
|
||||
accountBody.email === required("SMOKE_OWNER_EMAIL") &&
|
||||
Object.keys(accountBody).sort().join(",") === "email,name,preferences",
|
||||
"GET /api/v1/account returns the signed-in person's own name, email, and preferences only",
|
||||
);
|
||||
const changePreferences = async (body) => {
|
||||
const response = await fetch(`${apiOrigin}/api/v1/account/preferences`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
|
||||
method: "PATCH",
|
||||
});
|
||||
return { body: await response.json(), status: response.status };
|
||||
};
|
||||
const saved = await changePreferences({ motion: "reduce" });
|
||||
check(
|
||||
saved.status === 200 && saved.body.motion === "reduce",
|
||||
"PATCH /api/v1/account/preferences saves a change to the person's own account",
|
||||
);
|
||||
const restored = await changePreferences(accountBody.preferences);
|
||||
check(restored.status === 200, "the account's previous preferences are put back");
|
||||
const anonymousAccount = await fetch(`${apiOrigin}/api/v1/account`, {
|
||||
headers: { origin: dashboardOrigin },
|
||||
});
|
||||
await anonymousAccount.arrayBuffer();
|
||||
check(anonymousAccount.status === 403, "an anonymous caller is refused the account");
|
||||
|
||||
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
|
||||
body: "{}",
|
||||
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
|
||||
@@ -491,6 +569,25 @@ async function moduleChecks() {
|
||||
check(installed, `the module inventory lists the \`${moduleId}\` module`);
|
||||
}
|
||||
|
||||
// The module's own Dashboard address (ADR 0057) exists exactly while the module is installed with a
|
||||
// Dashboard UI — disabled or not: the Dashboard page when it is, the API's sanitized 404 when it is
|
||||
// not. A disabled module is not running, but its address still opens the Dashboard.
|
||||
const pageExpectation =
|
||||
optional("SMOKE_MODULE_PAGE") || (expectation === "absent" ? "absent" : "served");
|
||||
const modulePage = await fetch(`${dashboardOrigin}/${moduleId}`);
|
||||
const modulePageBody = await modulePage.text();
|
||||
if (pageExpectation === "absent") {
|
||||
check(
|
||||
modulePage.status === 404 && modulePageBody === '{"code":"NOT_FOUND"}',
|
||||
`GET /${moduleId} is the sanitized 404 while the module is not installed`,
|
||||
);
|
||||
} else {
|
||||
check(
|
||||
modulePage.status === 200 && modulePageBody.includes('id="ekklesia-host"'),
|
||||
`GET /${moduleId} through the proxy is the Dashboard page while the module is installed`,
|
||||
);
|
||||
}
|
||||
|
||||
if (moduleId === "example") {
|
||||
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
|
||||
if (expectation === "absent") {
|
||||
|
||||
+10
-3
@@ -543,9 +543,13 @@ recreate_api() {
|
||||
fi
|
||||
}
|
||||
|
||||
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
|
||||
# module_check <expectation> [page]: "absent", or the exact JSON array of values `example` must
|
||||
# return. `page` is whether its Dashboard address is served (ADR 0057): by default, exactly when the
|
||||
# module is running; "served" for a module that is installed but disabled, whose address still opens
|
||||
# the Dashboard so it can say the module is unavailable.
|
||||
module_check() {
|
||||
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
|
||||
-e SMOKE_MODULE_PAGE="${2:-}" \
|
||||
-e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \
|
||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
|
||||
}
|
||||
@@ -603,8 +607,11 @@ if [[ "$topology" == split ]]; then
|
||||
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's default 64 MiB /dev/shm is too small for one tab that loads the Dashboard, a module,
|
||||
# and its live streams dozens of times: Chromium then fails requests with
|
||||
# ERR_INSUFFICIENT_RESOURCES partway through. A real desktop has far more.
|
||||
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
|
||||
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp --shm-size 512m \
|
||||
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
|
||||
"$browser_image" \
|
||||
"--ignore-certificate-errors-spki-list=${spki}" \
|
||||
@@ -729,7 +736,7 @@ if [[ "$topology" == split ]]; then
|
||||
step "Disabling and re-enabling the module through the supported lifecycle command"
|
||||
restart_api "${compose[@]}" run --rm --no-deps migrate node \
|
||||
core/dist/cli/set-module-enabled.js --disable --module "$module_id"
|
||||
module_check absent || fail "the disabled module was still reported as running."
|
||||
module_check absent served || fail "the disabled module was still reported as running."
|
||||
module_storage "test -d /modules/${module_id}" ||
|
||||
fail "disabling the module removed its installed package."
|
||||
restart_api "${compose[@]}" run --rm --no-deps migrate node \
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user