From 300d9eab51889db0ef2f5af2a89c4a73c8e1fc4e Mon Sep 17 00:00:00 2001 From: ekklesia Date: Fri, 16 Oct 2026 16:38:32 -0500 Subject: [PATCH] Pair displays at the platform's own /display/setup, with or without modules Displays are platform-owned, but a screen could only start pairing on a module's display page: every pairing request named a module, so an installation with no module that uses displays could not set one up. - /display/setup is the platform's own display page, served whenever the deployment has device support, whatever is installed. It pairs a platform display with no module. Once paired, it opens the module it is set to show; with none set, it shows its name and waits, rechecking so it stays alive and notices revocation. "setup" is now a reserved module id. - Migration core:0009_platform_display_pairing lets a pairing request name no module. It relaxes module_id and permission_id and adds a both-or-neither check. It is additive and destroys nothing. - POST /api/v1/device-pairing/requests accepts {} for a platform display. Approval accepts an optional moduleId, so the administrator can choose an installed display module and the device gets that module's declared permission. A module display's own request can't be redirected. - GET /api/v1/device-pairing/self lets a paired device describe itself: its name and the module displays it may show. It renews the device's cookie. - GET /api/v1/devices/setup is replaced by GET /api/v1/devices/display-modules: the modules a display can be set to show. - Settings -> Displays always offers the /display/setup address, Copy setup link and Add display. Add display has an optional "Show on this screen" choice, and each paired display says what it shows. - /display/ is unchanged, so released Signage 0.1.5 works as before. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +- README.md | 5 +- apps/api/src/app.ts | 10 +- apps/api/src/device-routes.ts | 156 +- apps/api/src/module-display-path.ts | 7 + apps/api/src/module-display-routes.ts | 42 +- apps/api/src/server.ts | 7 +- apps/api/tests/api-http.integration.ts | 1 + apps/api/tests/dashboard-host-routes.test.ts | 17 +- apps/api/tests/device-pairing.integration.ts | 245 ++- apps/api/tests/lifecycle.test.ts | 16 +- apps/api/tests/module-database.integration.ts | 2 + apps/api/tests/module-display-routes.test.ts | 48 +- .../drizzle/0009_platform_display_pairing.sql | 3 + core/drizzle/meta/0009_snapshot.json | 1438 +++++++++++++++++ core/drizzle/meta/_ekklesia.json | 6 + core/drizzle/meta/_journal.json | 7 + core/src/devices/schema.ts | 24 +- core/src/devices/store.ts | 111 +- core/src/modules/reserved-ids.ts | 15 +- core/tests/account-store.integration.ts | 1 + core/tests/authorization-store.integration.ts | 1 + core/tests/dashboard-store.integration.ts | 1 + core/tests/device-pairing-schema.test.ts | 53 + .../module-configuration-store.integration.ts | 1 + core/tests/module-discovery.test.ts | 9 +- .../module-lifecycle-store.integration.ts | 1 + ...036-managed-device-identity-and-pairing.md | 1 + ...etup-phone-forms-and-drawer-scroll-lock.md | 173 +- docs/architecture/decisions/README.md | 4 +- docs/development/api.md | 20 +- docs/development/testing.md | 32 +- scripts/lib/deploy-smoke-browser.mjs | 149 +- scripts/lib/deploy-smoke-client.mjs | 24 + .../src/browser/DisplaysSettingsPage.tsx | 260 +-- surfaces/shell/src/browser/scroll-lock.ts | 4 +- .../shell/src/display/DisplaySetupApp.tsx | 141 ++ surfaces/shell/src/display/PairingScreen.tsx | 35 +- surfaces/shell/src/display/main.tsx | 7 +- surfaces/shell/src/host-protocol.ts | 121 +- surfaces/shell/src/index.ts | 2 + .../shell/tests/display-setup-app.test.tsx | 179 ++ .../shell/tests/displays-settings.test.tsx | 177 +- .../shell/tests/settings-navigation.test.tsx | 4 +- 44 files changed, 3169 insertions(+), 396 deletions(-) create mode 100644 core/drizzle/0009_platform_display_pairing.sql create mode 100644 core/drizzle/meta/0009_snapshot.json create mode 100644 core/tests/device-pairing-schema.test.ts create mode 100644 surfaces/shell/src/display/DisplaySetupApp.tsx create mode 100644 surfaces/shell/tests/display-setup-app.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index a4e2c27..520574f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,8 +54,9 @@ Rules from those documents that every change here must respect: (`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour. - **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited in product UI. Destructive actions use in-product confirmation proportional to their impact. -- **Displays are platform-owned.** Display pairing lives in **Settings → Displays** (ADR 0064); the - sidebar is navigation only. Signage's own display roster is still a transitional implementation +- **Displays are platform-owned.** A screen starts at the platform's own `/display/setup`, whatever + modules are installed, and is paired in **Settings → Displays** (ADR 0064, ADR 0065); the sidebar + is navigation only. Signage's own display roster is still a transitional implementation that Stage 1 replaces with the platform's displays. Keep existing paired displays working across any change, and do not build a module installer, theme editor, or display features beyond what a task asks for early. diff --git a/README.md b/README.md index 679ab3b..ba174dd 100644 --- a/README.md +++ b/README.md @@ -955,8 +955,9 @@ links to where each is changed; the platform's General, Branding, Displays, Disc each module's settings at `/settings/`. Discover is where modules are found and installed (`/settings/discover`), with Installed and Updates beneath it (`/settings/discover/installed`, `/updates`); configuring a module is its own Settings page. **Settings → Displays** sets up, pairs, and revokes -display screens: how to start a new screen (the platform display page to open on it, with Copy link), -the paired displays with when each was last seen, and an *Add display* flow for the code a screen shows +display screens, whatever modules are installed: a new screen opens the platform's own +`/display/setup` and shows a code; *Add display* names it and, optionally, chooses which installed +module it shows; the paired displays show what each shows and when it was last seen ([ADR 0064](docs/architecture/decisions/0064-stage-1-shell-review-corrections.md), [ADR 0065](docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md)). A person sees only what the server says they may administer, and no Settings link at all when that is diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f37e745..727c8dc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,12 +3,12 @@ import { type ModuleSecretSource, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; +import type { AccountStore } from "@ekklesia/core/account"; import type { AuthorizationEvaluator, 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,8 +28,8 @@ import type { 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 { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; import type { DashboardHostAssets } from "./dashboard-host-assets.js"; import { createDashboardHostRoutes } from "./dashboard-host-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js"; @@ -88,8 +88,9 @@ export interface CreateApiAppOptions { readonly secureCookies: boolean; }; /** Surface-owned display host assets, read once by production startup. Optional only when the - * eligible publication registry contains no display entrypoint; route composition rejects an - * inconsistent display-bearing registry synchronously. */ + * eligible publication registry contains no display entrypoint and `devices` is absent: device + * support always serves the platform's `/display/setup` page (ADR 0065). Route composition rejects + * an inconsistent composition synchronously. */ readonly displayHostAssets?: DisplayHostAssets; /** * The platform's operational module lifecycle: the store holding which modules are enabled, and the @@ -424,6 +425,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono { ...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}), ...(options.devices ? { + platformSetup: true, renewDeviceCredential: (c: Context) => renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true), } diff --git a/apps/api/src/device-routes.ts b/apps/api/src/device-routes.ts index f9578b5..da1cdd2 100644 --- a/apps/api/src/device-routes.ts +++ b/apps/api/src/device-routes.ts @@ -1,4 +1,4 @@ -import { safeParseEkklesiaDeviceId } from "@ekklesia/contracts"; +import { type PermissionId, safeParseEkklesiaDeviceId } from "@ekklesia/contracts"; import { CORE_DEVICES_MANAGE } from "@ekklesia/core/authorization"; import type { DeviceStore } from "@ekklesia/core/devices"; import { DEVICE_CREDENTIAL_TTL_MS } from "@ekklesia/core/devices"; @@ -131,6 +131,17 @@ export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): vo export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { const routes = new HonoApp(); const now = options.now ?? ((): Date => new Date()); + /** A running module display by id, with its declared display permission, or `undefined`. */ + const findDisplay = ( + moduleId: string, + ): { readonly moduleId: string; readonly permissionId: PermissionId } | undefined => { + const display = options.moduleUi + .listDisplays() + .find((candidate) => candidate.moduleId === moduleId); + return display === undefined + ? undefined + : { moduleId: display.moduleId, permissionId: display.displayPermission }; + }; const requireManage = requirePermission(options, CORE_DEVICES_MANAGE); const requireOrigin = requireTrustedOrigin(options.trustedOrigins); const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES); @@ -138,11 +149,16 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { /** * An unpaired display asks to be paired. * - * The **only** thing the caller supplies is which module's display it is sitting on, and even - * that is not taken at its word: the module is looked up in the publication registry and the - * permission is read from that module's validated display declaration. A browser therefore cannot - * ask to be granted `core.devices.manage`, another module's permission, or anything else — the - * request records the permission the host derived, and approval grants exactly that. + * Two kinds of screen ask (ADR 0065). One on the platform's own setup page (`/display/setup`) + * sends `{}`: it becomes a platform display with no module, and the approving administrator may + * choose which installed display module it shows. One on a module's display page sends + * `{ "moduleId": … }`, as released displays always have. + * + * The module, when there is one, is not taken at its word: it is looked up in the publication + * registry and the permission is read from that module's validated display declaration. A browser + * therefore cannot ask to be granted `core.devices.manage`, another module's permission, or + * anything else — any other key is ignored, the request records the permission the host derived, + * or none, and approval grants exactly that. */ routes.post( `${DEVICE_PAIRING_PREFIX}/requests`, @@ -150,25 +166,29 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { mutationBodyLimit, async (c: Context) => { const body = await readJsonObject(c); - const moduleId = body === undefined ? undefined : readString(body, "moduleId", 128); - if (moduleId === undefined) { + if (body === undefined) { return sanitizedFailure(c, 400, "INVALID_REQUEST"); } - const display = options.moduleUi - .listDisplays() - .find((candidate) => candidate.moduleId === moduleId); - if (!display) { - // Unknown, dashboard-only, disabled, or dependency-blocked all answer the same way, so - // pairing never becomes a way to enumerate what is installed. - return sanitizedFailure(c, 404, "NOT_FOUND"); + let display: { readonly moduleId: string; readonly permissionId: PermissionId } | undefined; + if ("moduleId" in body) { + const moduleId = readString(body, "moduleId", 128); + if (moduleId === undefined) { + return sanitizedFailure(c, 400, "INVALID_REQUEST"); + } + const found = findDisplay(moduleId); + if (!found) { + // Unknown, dashboard-only, disabled, or dependency-blocked all answer the same way, so + // pairing never becomes a way to enumerate what is installed. + return sanitizedFailure(c, 404, "NOT_FOUND"); + } + display = found; } try { const at = now(); const created = await options.devices.createPairingRequest({ - moduleId: display.moduleId, + ...(display === undefined ? {} : { display }), now: at, - permissionId: display.displayPermission, }); writeDeviceCookie( c, @@ -265,8 +285,9 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { return c.json( { expiresAt: found.expiresAt.toISOString(), - moduleId: found.moduleId, - permissionId: found.permissionId, + // `null` for a platform display no module has been chosen for yet (ADR 0065). + moduleId: found.moduleId ?? null, + permissionId: found.permissionId ?? null, requestedAt: found.createdAt.toISOString(), }, 200, @@ -277,7 +298,15 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { }, ); - /** Approval. The administrator names the device; the unpaired screen never names itself. */ + /** + * Approval. The administrator names the device; the unpaired screen never names itself. + * + * `moduleId`, optional, is the installed display module the administrator chose for a platform + * display to show (ADR 0065). It must be a running display, and the permission granted is that + * module's declared display permission, never one the request names. A screen that asked from a + * module's own display page already names its module; a different `moduleId` is refused, so its + * permission can never be swapped for another module's. + */ routes.post( `${DEVICE_PAIRING_PREFIX}/approvals`, requireOrigin, @@ -287,9 +316,22 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { const body = await readJsonObject(c); const code = body === undefined ? undefined : readString(body, "code", 16); const deviceName = body === undefined ? undefined : readString(body, "deviceName", 128); - if (code === undefined || !/^[0-9]{6}$/u.test(code) || deviceName === undefined) { + if ( + body === undefined || + code === undefined || + !/^[0-9]{6}$/u.test(code) || + deviceName === undefined + ) { return sanitizedFailure(c, 400, "INVALID_REQUEST"); } + let display: { readonly moduleId: string; readonly permissionId: PermissionId } | undefined; + if ("moduleId" in body) { + const moduleId = readString(body, "moduleId", 128); + display = moduleId === undefined ? undefined : findDisplay(moduleId); + if (display === undefined) { + return sanitizedFailure(c, 400, "INVALID_REQUEST"); + } + } const principal = getAuthorizedPrincipal(c); if (principal.kind !== "authenticated") { // Unreachable through `requireManage`, which only ever admits a person. Stated rather than @@ -301,6 +343,7 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { approvedBy: principal.userId, code, deviceName, + ...(display === undefined ? {} : { display }), now: now(), }); if (!approved) { @@ -309,7 +352,10 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN"); } noStore(c); - return c.json({ moduleId: approved.moduleId, permissionId: approved.permissionId }, 200); + return c.json( + { moduleId: approved.moduleId ?? null, permissionId: approved.permissionId ?? null }, + 200, + ); } catch { return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE"); } @@ -317,31 +363,75 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { ); /** - * How a new screen starts pairing (ADR 0065): the platform display page for every running module - * that shows content on screens, for Settings → Displays to tell an administrator what to open on - * the screen. Each page is the generic display host at `/display/` (ADR 0033), which - * shows a pairing code until the screen is approved. + * The modules a display can be set to show (ADR 0065): every running module with a display, by + * name, with the display permission showing it requires, for Settings → Displays' "Show on this + * screen" choice and to name what each paired display shows. * * Administrative, like the device list beside it: `core.devices.manage`, and never anonymous, so - * this does not become a way to enumerate what is installed — an unpaired screen still learns - * nothing beyond the page it was told to open. It answers names and root-relative page paths only; - * the browser resolves them against the API origin, which is where the display host lives in both - * topologies (ADR 0045). An empty list is a real answer: no installed module uses displays yet. + * it is not a way to enumerate what is installed. Setting up a screen needs none of it — that is + * the platform's own `/display/setup`, whatever is installed. An empty list is a real answer: no + * installed module uses displays yet, and a display paired now simply shows nothing until one is. */ - routes.get(`${DEVICES_PREFIX}/setup`, requireManage, (c: Context) => { + routes.get(`${DEVICES_PREFIX}/display-modules`, requireManage, (c: Context) => { noStore(c); return c.json( { - displays: options.moduleUi.listDisplays().map((display) => ({ + modules: options.moduleUi.listDisplays().map((display) => ({ moduleId: display.moduleId, name: display.name, - url: moduleDisplayPageUrl(display.moduleId), + permissionId: display.displayPermission, })), }, 200, ); }); + /** + * A paired display asking about itself (ADR 0065): its name, and the module displays it may show + * now. The platform's setup page asks this to know whether to open a module's display, to show + * "paired as Foyer TV" while nothing is assigned, and to notice revocation. Asking also keeps an + * idle display's credential alive, exactly like a module display's descriptor (ADR 0056). + * + * Only a device is answered. A person — even an administrator who opened the page on their own + * laptop — and an unpaired screen are refused, so this reveals nothing to anyone else. + */ + routes.get(`${DEVICE_PAIRING_PREFIX}/self`, async (c: Context) => { + noStore(c); + c.header("Vary", "Cookie"); + let principal: Awaited>; + try { + principal = await options.runtime.resolvePrincipal(c.req.raw.headers); + } catch { + return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE"); + } + if (principal.kind !== "device") { + return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED"); + } + try { + const self = await options.devices.describeDevice(principal.deviceId); + if (!self) { + return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED"); + } + renewDeviceSessionCookie(c, options.secureCookies); + const held = new Set(self.permissions); + return c.json( + { + displays: options.moduleUi + .listDisplays() + .filter((display) => held.has(display.displayPermission)) + .map((display) => ({ + moduleId: display.moduleId, + url: moduleDisplayPageUrl(display.moduleId), + })), + name: self.name, + }, + 200, + ); + } catch { + return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE"); + } + }); + routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => { try { const devices = await options.devices.listDevices(); diff --git a/apps/api/src/module-display-path.ts b/apps/api/src/module-display-path.ts index b99b639..4549af1 100644 --- a/apps/api/src/module-display-path.ts +++ b/apps/api/src/module-display-path.ts @@ -14,3 +14,10 @@ export function moduleDisplayPageUrl(moduleId: string): string { export function moduleDisplayDescriptorUrl(moduleId: string): string { return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`; } + +/** + * Where a new screen starts pairing as a platform display, before any module is chosen for it (ADR + * 0065). `setup` is a reserved module id (`RESERVED_DISPLAY_HOST_NAMES`), so no module's display can + * take this address. + */ +export const DISPLAY_SETUP_PAGE_URL = `${MODULE_DISPLAY_PAGE_PREFIX}/setup`; diff --git a/apps/api/src/module-display-routes.ts b/apps/api/src/module-display-routes.ts index 4429e58..89f1fe5 100644 --- a/apps/api/src/module-display-routes.ts +++ b/apps/api/src/module-display-routes.ts @@ -8,13 +8,20 @@ import { } from "./authorization-middleware.js"; import { etagMatches } from "./conditional-request.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js"; + export { + DISPLAY_SETUP_PAGE_URL, MODULE_DISPLAY_DESCRIPTOR_PREFIX, MODULE_DISPLAY_PAGE_PREFIX, moduleDisplayDescriptorUrl, moduleDisplayPageUrl, } from "./module-display-path.js"; -import { moduleDisplayDescriptorUrl, moduleDisplayPageUrl } from "./module-display-path.js"; + +import { + DISPLAY_SETUP_PAGE_URL, + moduleDisplayDescriptorUrl, + moduleDisplayPageUrl, +} from "./module-display-path.js"; import { moduleApiBaseUrl } from "./module-http-routes.js"; import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js"; import { @@ -80,9 +87,16 @@ export interface HostModuleDisplayResource { export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions { /** The same immutable, eligible/running publication registry used for artifact bytes. */ readonly moduleUi: ModuleUiPublicationRegistry; - /** Omitted only when the registry contains no display entrypoints. Production reads the exact - * surface-owned files before binding and supplies their frozen contents here. */ + /** Omitted only when the registry contains no display entrypoints and the deployment has no + * device support. Production reads the exact surface-owned files before binding and supplies their + * frozen contents here. */ readonly assets?: DisplayHostAssets; + /** + * Serve the platform's own setup page, `/display/setup`, where a new screen pairs as a platform + * display before any module is chosen for it (ADR 0065). Composed whenever the deployment has + * device support, whatever is installed — including no module with a display at all. + */ + readonly platformSetup?: boolean; /** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the * deployment has device support; called only for a request that resolved to a device. */ readonly renewDeviceCredential?: (c: Context) => void; @@ -229,7 +243,7 @@ function mountDisplay( */ export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutesOptions): void { const displays = options.moduleUi.listDisplays(); - if (displays.length === 0) { + if (displays.length === 0 && options.platformSetup !== true) { return; } if (!options.assets) { @@ -290,6 +304,26 @@ export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutes }); }); + /** + * The platform's setup page (ADR 0065): the same inert, module-agnostic page bytes as every module + * display, so it carries nothing. The browser reads `setup` from its own address and asks to be + * paired with no module; what it may show once paired comes from the device's own permissions, + * through `/api/v1/device-pairing/self`. `setup` is a reserved module id, so no module's display + * can take this address. + */ + if (options.platformSetup === true) { + app.get(DISPLAY_SETUP_PAGE_URL, () => { + return new Response(assets.html, { + headers: { + "Cache-Control": "no-store", + "Content-Type": "text/html; charset=utf-8", + "X-Content-Type-Options": "nosniff", + }, + status: 200, + }); + }); + } + for (const display of displays) { mountDisplay(app, { ...options, assets }, display); } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 6ff4758..a8eeceb 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -2,11 +2,11 @@ import type { Socket } from "node:net"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts"; +import { createAccountStore } from "@ekklesia/core/account"; 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"; @@ -554,8 +554,9 @@ export async function startApi( let displayHostAssets: Awaited> | undefined; let dashboardHostAssets: Awaited>; try { - displayHostAssets = - moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets(); + // Unconditional: every installation can pair a screen at `/display/setup`, whatever modules are + // installed (ADR 0065), so a missing display host build fails here too. + displayHostAssets = await loadDisplayHostAssets(); // Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044), // so a missing shell build fails here, before any module code is imported. // The page learns its API origin from the API that serves it, so one build serves either diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index c0c0d81..a00aca0 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -261,6 +261,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0006_device_identity", "core:0007_verified_bootstrap_owners", "core:0008_user_preferences", + "core:0009_platform_display_pairing", ]); expect(aggregate.migrations[0]?.checksum).toBe( "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", diff --git a/apps/api/tests/dashboard-host-routes.test.ts b/apps/api/tests/dashboard-host-routes.test.ts index 2251f99..c7b2a62 100644 --- a/apps/api/tests/dashboard-host-routes.test.ts +++ b/apps/api/tests/dashboard-host-routes.test.ts @@ -20,6 +20,7 @@ import { type LegacyModuleRuntimeServices, type ModuleDatabase, type ModuleLifecycleConfiguration, + RESERVED_DISPLAY_HOST_NAMES, RESERVED_MODULE_IDS, RESERVED_SETTINGS_NAMES, RESERVED_TOP_LEVEL_NAMES, @@ -44,6 +45,7 @@ import { createDashboardHostRoutes, } from "../src/dashboard-host-routes.js"; import { MODULE_NAVIGATION_ICONS } from "@ekklesia/contracts"; +import { DISPLAY_SETUP_PATH as SHELL_DISPLAY_SETUP_PATH } from "@ekklesia/shell"; import { DASHBOARD_PLATFORM_PAGES, HOST_NAMESPACES, @@ -51,6 +53,7 @@ import { } from "@ekklesia/shell/dashboard-routes"; import { DASHBOARD_MODULE_ICON_NAMES } from "@ekklesia/shell/module-icons"; import { + DISPLAY_SETUP_PAGE_URL, MODULE_DISPLAY_DESCRIPTOR_PREFIX, MODULE_DISPLAY_PAGE_PREFIX, } from "../src/module-display-path.js"; @@ -437,9 +440,21 @@ describe("the platform's reserved top-level names (ADR 0057)", () => { expect(isReservedModuleId(section), section).toBe(true); } expect(RESERVED_MODULE_IDS).toEqual( - [...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(), + [ + ...RESERVED_TOP_LEVEL_NAMES, + ...RESERVED_SETTINGS_NAMES, + ...RESERVED_DISPLAY_HOST_NAMES, + ].sort(), ); }); + + it("include the display host's own setup page, so /display/ never collides (ADR 0065)", () => { + expect([...RESERVED_DISPLAY_HOST_NAMES]).toEqual(["setup"]); + expect(DISPLAY_SETUP_PAGE_URL).toBe("/display/setup"); + // The shell opens and links to exactly the address the API serves. + expect(SHELL_DISPLAY_SETUP_PATH).toBe(DISPLAY_SETUP_PAGE_URL); + expect(isReservedModuleId(DISPLAY_SETUP_PAGE_URL.split("/")[2] ?? "")).toBe(true); + }); }); describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => { diff --git a/apps/api/tests/device-pairing.integration.ts b/apps/api/tests/device-pairing.integration.ts index afce368..bb7b92e 100644 --- a/apps/api/tests/device-pairing.integration.ts +++ b/apps/api/tests/device-pairing.integration.ts @@ -99,6 +99,12 @@ async function query( /** A display-capable module: one display entrypoint, one restricted permission protecting it, and * one realtime channel gated by that same permission. */ +async function createEmptyModulesDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-empty-")); + trackedDirectories.push(directory); + return directory; +} + async function createDisplayModuleDirectory(): Promise { const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-")); trackedDirectories.push(directory); @@ -267,9 +273,17 @@ interface Platform { readonly close: () => Promise; } -async function platform(label: string): Promise { +async function platform( + label: string, + options: { readonly modules?: boolean } = {}, +): Promise { const url = await createMigratedDatabase(label); - const modulesDirectory = await createDisplayModuleDirectory(); + // `modules: false` is an installation with no module at all — nothing that shows content on a + // screen — which can still set up and pair a display (ADR 0065). + const modulesDirectory = + options.modules === false + ? await createEmptyModulesDirectory() + : await createDisplayModuleDirectory(); const api = await startPlatform(url, modulesDirectory); const origin = apiOrigin(api); const ownerCookie = await createOwner(url, origin); @@ -419,27 +433,6 @@ describe.sequential("display device pairing, end to end", () => { expect((await device.request("/display/announcements")).status).toBe(200); expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403); - // How an administrator learns which page to open on that screen (ADR 0065): the platform - // display page of every module that uses displays, named, and only for an administrator. The - // screen itself — still anonymous — is refused, so setup never enumerates what is installed. - const setup = await adminRequest( - running.origin, - running.ownerCookie, - "/api/v1/devices/setup", - ); - expect(setup.status).toBe(200); - expect(setup.headers.get("cache-control")).toBe("no-store"); - expect(await setup.json()).toEqual({ - displays: [ - { - moduleId: "announcements", - name: "Announcements fixture", - url: "/display/announcements", - }, - ], - }); - expect((await device.request("/api/v1/devices/setup")).status).toBe(403); - // 2. It asks to be paired and receives a code plus an HttpOnly claim cookie. const requested = await device.post("/api/v1/device-pairing/requests", { moduleId: "announcements", @@ -522,7 +515,7 @@ describe.sequential("display device pairing, end to end", () => { }); expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403); expect((await device.request("/api/v1/devices")).status).toBe(403); - expect((await device.request("/api/v1/devices/setup")).status).toBe(403); + expect((await device.request("/api/v1/devices/display-modules")).status).toBe(403); // 9. The administrator revokes it. const devices = (await ( @@ -951,3 +944,207 @@ describe.sequential("display device pairing, end to end", () => { } }); }); + +describe.sequential("platform display pairing (ADR 0065)", () => { + it("pairs a screen from /display/setup with zero display modules installed", async () => { + const running = await platform("platform-zero", { modules: false }); + const device = new DeviceBrowser(running.origin); + try { + // The platform's own setup page is served with nothing installed, and nothing else is. + const page = await device.request("/display/setup"); + expect(page.status).toBe(200); + expect(await page.text()).toContain("ekklesia-display"); + expect((await device.request("/display/signage")).status).toBe(404); + + // It asks to be paired as a platform display: no module at all. + const requested = await device.post("/api/v1/device-pairing/requests", {}); + expect(requested.status).toBe(201); + const { code } = (await requested.json()) as { code: string }; + expect(code).toMatch(/^[0-9]{6}$/u); + + // The administrator sees a screen with no module yet, and there is no module to choose. + const lookup = await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/lookups", + { code }, + ); + expect(lookup.status).toBe(200); + expect(await lookup.json()).toMatchObject({ moduleId: null, permissionId: null }); + const modules = await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/devices/display-modules", + ); + expect(modules.headers.get("cache-control")).toBe("no-store"); + expect(await modules.json()).toEqual({ modules: [] }); + const approved = await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/approvals", + { code, deviceName: "Foyer TV" }, + ); + expect(approved.status).toBe(200); + expect(await approved.json()).toEqual({ moduleId: null, permissionId: null }); + + // The screen collects its credential exactly as a module display does. + const claimed = await device.post("/api/v1/device-pairing/claims", {}); + expect(await claimed.json()).toEqual({ status: "paired" }); + expect(device.has("ekklesia_device_session")).toBe(true); + + // It is a platform display: paired, named, holding no module permission. + const self = await device.request("/api/v1/device-pairing/self"); + expect(self.status).toBe(200); + expect(self.headers.get("cache-control")).toBe("no-store"); + expect(setCookieFor(self, "ekklesia_device_session")).toMatch(/HttpOnly/iu); + expect(await self.json()).toEqual({ displays: [], name: "Foyer TV" }); + const listed = (await ( + await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices") + ).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> }; + expect(listed.devices).toHaveLength(1); + expect(listed.devices[0]).toMatchObject({ name: "Foyer TV", permissions: [] }); + + // Only a device is answered about itself: never a person, never an unpaired screen. + expect( + (await adminRequest(running.origin, running.ownerCookie, "/api/v1/device-pairing/self")) + .status, + ).toBe(403); + expect( + (await new DeviceBrowser(running.origin).request("/api/v1/device-pairing/self")).status, + ).toBe(403); + + // Revoking it works as for any display, and it can start over at the same page. + const revoked = await adminRequest( + running.origin, + running.ownerCookie, + `/api/v1/devices/${listed.devices[0]?.id}/revoke`, + {}, + ); + expect(revoked.status).toBe(204); + expect((await device.request("/api/v1/device-pairing/self")).status).toBe(403); + expect((await device.request("/display/setup")).status).toBe(200); + } finally { + await running.close(); + } + }); + + it("lets the administrator choose the module a platform display shows when approving it", async () => { + const running = await platform("platform-assign"); + const device = new DeviceBrowser(running.origin); + try { + const requested = await device.post("/api/v1/device-pairing/requests", {}); + const { code } = (await requested.json()) as { code: string }; + + const modules = await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/devices/display-modules", + ); + expect(await modules.json()).toEqual({ + modules: [ + { + moduleId: "announcements", + name: "Announcements fixture", + permissionId: displayPermission, + }, + ], + }); + const approved = await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/approvals", + { code, deviceName: "Hall screen", moduleId: "announcements" }, + ); + expect(approved.status).toBe(200); + expect(await approved.json()).toEqual({ + moduleId: "announcements", + permissionId: displayPermission, + }); + expect(await (await device.post("/api/v1/device-pairing/claims", {})).json()).toEqual({ + status: "paired", + }); + + // The one platform display now holds exactly that module's display permission, so the setup + // page knows where to go and the module's display accepts it. + expect(await (await device.request("/api/v1/device-pairing/self")).json()).toEqual({ + displays: [{ moduleId: "announcements", url: "/display/announcements" }], + name: "Hall screen", + }); + expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200); + const listed = (await ( + await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices") + ).json()) as { devices: Array<{ permissions: string[] }> }; + expect(listed.devices).toHaveLength(1); + expect(listed.devices[0]?.permissions).toEqual([displayPermission]); + } finally { + await running.close(); + } + }); + + it("never takes a permission from a request, and never redirects a module display's own one", async () => { + const running = await platform("platform-refusals"); + const device = new DeviceBrowser(running.origin); + try { + // A request can never name a permission: any other key is ignored, so this is a platform + // request that grants nothing. A module that is not a string is refused. + const ignored = await device.post("/api/v1/device-pairing/requests", { + permissionId: "core.devices.manage", + }); + expect(ignored.status).toBe(201); + const ignoredCode = ((await ignored.json()) as { code: string }).code; + expect( + await ( + await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/lookups", + { + code: ignoredCode, + }, + ) + ).json(), + ).toMatchObject({ moduleId: null, permissionId: null }); + expect( + ( + await new DeviceBrowser(running.origin).post("/api/v1/device-pairing/requests", { + moduleId: null, + }) + ).status, + ).toBe(400); + + const requested = await device.post("/api/v1/device-pairing/requests", { + moduleId: "announcements", + }); + const { code } = (await requested.json()) as { code: string }; + // A module that is not a running display is refused at approval. + expect( + ( + await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/approvals", + { + code, + deviceName: "Screen", + moduleId: "not-a-display", + }, + ) + ).status, + ).toBe(400); + // Naming its own module again is fine: the request is unchanged. + const approved = await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/approvals", + { code, deviceName: "Screen", moduleId: "announcements" }, + ); + expect(approved.status).toBe(200); + expect(await approved.json()).toEqual({ + moduleId: "announcements", + permissionId: displayPermission, + }); + } finally { + await running.close(); + } + }); +}); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index 7cd7aa6..2be06f5 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -24,8 +24,16 @@ vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => { ); return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock }; }); +// Every startup also reads the built display host, because every installation serves the platform's +// `/display/setup` page (ADR 0065), so this loader answers a small consistent snapshot too. +const defaultDisplayHostAssets = vi.hoisted(() => ({ factory: undefined as unknown })); vi.mock("../src/display-host-assets.js", async (importOriginal) => { const actual = await importOriginal(); + defaultDisplayHostAssets.factory = () => + actual.createDisplayHostAssets("", "export {};\n", "export {};\n"); + loadDisplayHostAssetsMock.mockImplementation(async () => + (defaultDisplayHostAssets.factory as () => unknown)(), + ); return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock }; }); // Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still @@ -1096,6 +1104,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => { drizzleAdapterMock.mockClear(); serveMock.mockReset(); loadDisplayHostAssetsMock.mockReset(); + loadDisplayHostAssetsMock.mockImplementation(async () => + (defaultDisplayHostAssets.factory as () => unknown)(), + ); vi.mocked(createApiApp).mockClear(); }); @@ -1159,7 +1170,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => { expect(serveMock).not.toHaveBeenCalled(); }); - it("does not read display-host assets when the eligible set declares no display", async () => { + it("reads display-host assets even when no module declares a display, for /display/setup (ADR 0065)", async () => { const modulesDirectory = await createTemporaryModulesDirectory(); const recorder = createEventRecorder(); await createWorkingFixtureModule(modulesDirectory, "calendar", recorder); @@ -1168,7 +1179,8 @@ describe("Ekklesia API module runtime startup and shutdown", () => { const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }); - expect(loadDisplayHostAssetsMock).not.toHaveBeenCalled(); + // A screen can be set up on any installation, whatever is installed. + expect(loadDisplayHostAssetsMock).toHaveBeenCalledTimes(1); await running.shutdown(); }); diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts index 7ea9d6a..68e3aa3 100644 --- a/apps/api/tests/module-database.integration.ts +++ b/apps/api/tests/module-database.integration.ts @@ -250,6 +250,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0006_device_identity", "core:0007_verified_bootstrap_owners", "core:0008_user_preferences", + "core:0009_platform_display_pairing", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -281,6 +282,7 @@ async function applyMigrationsWithModule(url: string): Promise { "core:0006_device_identity", "core:0007_verified_bootstrap_owners", "core:0008_user_preferences", + "core:0009_platform_display_pairing", "db-facade:0000_initial", ]); expect(aggregate.migrations[0]?.checksum).toBe( diff --git a/apps/api/tests/module-display-routes.test.ts b/apps/api/tests/module-display-routes.test.ts index 5b399f5..e85d59a 100644 --- a/apps/api/tests/module-display-routes.test.ts +++ b/apps/api/tests/module-display-routes.test.ts @@ -22,14 +22,16 @@ import { type LegacyModuleRuntimeServices, type ModuleDatabase, } from "@ekklesia/core/modules"; -import type { Hono } from "hono"; +import { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js"; import { DISPLAY_HOST_SCRIPT_PATH, DISPLAY_SERVICE_WORKER_PATH, + DISPLAY_SETUP_PAGE_URL, ModuleDisplayConfigurationError, + mountModuleDisplayRoutes, moduleDisplayDescriptorUrl, moduleDisplayPageUrl, } from "../src/module-display-routes.js"; @@ -568,3 +570,47 @@ describe("module display host asset publication", () => { } }); }); + +describe("the platform display setup page (ADR 0065)", () => { + function bare(options: { readonly platformSetup?: boolean; readonly assets?: boolean }) { + const app = new Hono(); + mountModuleDisplayRoutes(app, { + evaluator: createAuthorizationEvaluator({ + grants: { hasActiveGrant: async () => false }, + platformOwners: { isPlatformOwner: async () => false }, + }), + // No installed module has a display at all. + moduleUi: createModuleUiPublicationRegistry([]), + registry: composePlatformPermissionRegistry([]), + runtime: { + handleAuthRequest: vi.fn(), + resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), + }, + ...(options.assets === false ? {} : { assets: displayHostAssets }), + ...(options.platformSetup === undefined ? {} : { platformSetup: options.platformSetup }), + }); + return app; + } + + it("serves /display/setup and the display host with zero display modules installed", async () => { + const app = bare({ platformSetup: true }); + + expect(DISPLAY_SETUP_PAGE_URL).toBe("/display/setup"); + const page = await app.request(DISPLAY_SETUP_PAGE_URL); + expect(page.status).toBe(200); + expect(page.headers.get("cache-control")).toBe("no-store"); + // The same inert, module-agnostic page every display is served. + expect(await page.text()).toBe(displayHostAssets.html); + expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(200); + expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(200); + // Nothing else beneath /display/ exists. + expect((await app.request("/display/signage")).status).toBe(404); + }); + + it("is not served without device support, and device support needs the display host", async () => { + expect((await bare({}).request(DISPLAY_SETUP_PAGE_URL)).status).toBe(404); + expect(() => bare({ assets: false, platformSetup: true })).toThrow( + ModuleDisplayConfigurationError, + ); + }); +}); diff --git a/core/drizzle/0009_platform_display_pairing.sql b/core/drizzle/0009_platform_display_pairing.sql new file mode 100644 index 0000000..ab75710 --- /dev/null +++ b/core/drizzle/0009_platform_display_pairing.sql @@ -0,0 +1,3 @@ +ALTER TABLE "ekklesia_core_device_pairing_request" ALTER COLUMN "module_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "ekklesia_core_device_pairing_request" ALTER COLUMN "permission_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_module_check" CHECK (("ekklesia_core_device_pairing_request"."module_id" is null) = ("ekklesia_core_device_pairing_request"."permission_id" is null)); \ No newline at end of file diff --git a/core/drizzle/meta/0009_snapshot.json b/core/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..97c3316 --- /dev/null +++ b/core/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1438 @@ +{ + "id": "eaca8799-f403-4378-b5e7-d464e695ef7a", + "prevId": "dc0029d4-258f-4fbb-a794-acfbba0ec946", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ekklesia_core_account": { + "name": "ekklesia_core_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_account_user_id_idx": { + "name": "ekklesia_core_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_account_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_account", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_session": { + "name": "ekklesia_core_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_session_user_id_idx": { + "name": "ekklesia_core_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_session_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_session", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_session_token_unique": { + "name": "ekklesia_core_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_user": { + "name": "ekklesia_core_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_user_email_unique": { + "name": "ekklesia_core_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_verification": { + "name": "ekklesia_core_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_verification_identifier_idx": { + "name": "ekklesia_core_verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_authorization_audit": { + "name": "ekklesia_core_authorization_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_authorization_audit_subject_idx": { + "name": "ekklesia_core_authorization_audit_subject_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_authorization_audit_action_check": { + "name": "ekklesia_core_authorization_audit_action_check", + "value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')" + }, + "ekklesia_core_authorization_audit_actor_type_check": { + "name": "ekklesia_core_authorization_audit_actor_type_check", + "value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')" + }, + "ekklesia_core_authorization_audit_scope_check": { + "name": "ekklesia_core_authorization_audit_scope_check", + "value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_grant": { + "name": "ekklesia_core_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_grant_user_permission_idx": { + "name": "ekklesia_core_grant_user_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_grant", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_grant_source_kind_check": { + "name": "ekklesia_core_grant_source_kind_check", + "value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')" + }, + "ekklesia_core_grant_created_actor_type_check": { + "name": "ekklesia_core_grant_created_actor_type_check", + "value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_grant_revoked_actor_type_check": { + "name": "ekklesia_core_grant_revoked_actor_type_check", + "value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner": { + "name": "ekklesia_core_platform_owner", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_by_actor_type": { + "name": "granted_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by_actor_id": { + "name": "granted_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_platform_owner", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_granted_actor_type_check": { + "name": "ekklesia_core_platform_owner_granted_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_platform_owner_revoked_actor_type_check": { + "name": "ekklesia_core_platform_owner_revoked_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner_bootstrap": { + "name": "ekklesia_core_platform_owner_bootstrap", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumed_for_user_id": { + "name": "consumed_for_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_bootstrap_singleton_check": { + "name": "ekklesia_core_platform_owner_bootstrap_singleton_check", + "value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_dashboard_group": { + "name": "ekklesia_core_dashboard_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_dashboard_group_order_idx": { + "name": "ekklesia_core_dashboard_group_order_idx", + "columns": [ + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_dashboard_group_id_check": { + "name": "ekklesia_core_dashboard_group_id_check", + "value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0" + }, + "ekklesia_core_dashboard_group_label_check": { + "name": "ekklesia_core_dashboard_group_label_check", + "value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_dashboard_placement": { + "name": "ekklesia_core_dashboard_placement", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_dashboard_placement_group_idx": { + "name": "ekklesia_core_dashboard_placement_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": { + "name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk", + "tableFrom": "ekklesia_core_dashboard_placement", + "tableTo": "ekklesia_core_dashboard_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_dashboard_placement_module_check": { + "name": "ekklesia_core_dashboard_placement_module_check", + "value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_module_state": { + "name": "ekklesia_core_module_state", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_module_state_module_check": { + "name": "ekklesia_core_module_state_module_check", + "value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_module_configuration": { + "name": "ekklesia_core_module_configuration", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_module_configuration_module_idx": { + "name": "ekklesia_core_module_configuration_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ekklesia_core_module_configuration_module_id_key_pk": { + "name": "ekklesia_core_module_configuration_module_id_key_pk", + "columns": ["module_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_module_configuration_module_check": { + "name": "ekklesia_core_module_configuration_module_check", + "value": "length(\"ekklesia_core_module_configuration\".\"module_id\") > 0" + }, + "ekklesia_core_module_configuration_key_check": { + "name": "ekklesia_core_module_configuration_key_check", + "value": "length(\"ekklesia_core_module_configuration\".\"key\") > 0" + }, + "ekklesia_core_module_configuration_value_check": { + "name": "ekklesia_core_module_configuration_value_check", + "value": "length(\"ekklesia_core_module_configuration\".\"value\") <= 2048" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_device": { + "name": "ekklesia_core_device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_device_credential_hash_idx": { + "name": "ekklesia_core_device_credential_hash_idx", + "columns": [ + { + "expression": "credential_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ekklesia_core_device_revoked_idx": { + "name": "ekklesia_core_device_revoked_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["revoked_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_device_name_check": { + "name": "ekklesia_core_device_name_check", + "value": "length(\"ekklesia_core_device\".\"name\") between 1 and 128" + }, + "ekklesia_core_device_credential_hash_check": { + "name": "ekklesia_core_device_credential_hash_check", + "value": "length(\"ekklesia_core_device\".\"credential_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_device_pairing_request": { + "name": "ekklesia_core_device_pairing_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_device_pairing_code_idx": { + "name": "ekklesia_core_device_pairing_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"ekklesia_core_device_pairing_request\".\"state\" <> 'consumed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "ekklesia_core_device_pairing_claim_idx": { + "name": "ekklesia_core_device_pairing_claim_idx", + "columns": [ + { + "expression": "claim_secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device_pairing_request", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["approved_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk": { + "name": "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk", + "tableFrom": "ekklesia_core_device_pairing_request", + "tableTo": "ekklesia_core_device", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_device_pairing_code_check": { + "name": "ekklesia_core_device_pairing_code_check", + "value": "\"ekklesia_core_device_pairing_request\".\"code\" ~ '^[0-9]{6}$'" + }, + "ekklesia_core_device_pairing_claim_hash_check": { + "name": "ekklesia_core_device_pairing_claim_hash_check", + "value": "length(\"ekklesia_core_device_pairing_request\".\"claim_secret_hash\") = 64" + }, + "ekklesia_core_device_pairing_state_check": { + "name": "ekklesia_core_device_pairing_state_check", + "value": "\"ekklesia_core_device_pairing_request\".\"state\" in ('pending', 'approved', 'consumed')" + }, + "ekklesia_core_device_pairing_module_check": { + "name": "ekklesia_core_device_pairing_module_check", + "value": "(\"ekklesia_core_device_pairing_request\".\"module_id\" is null) = (\"ekklesia_core_device_pairing_request\".\"permission_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_device_permission": { + "name": "ekklesia_core_device_permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_device_permission_lookup_idx": { + "name": "ekklesia_core_device_permission_lookup_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk": { + "name": "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk", + "tableFrom": "ekklesia_core_device_permission", + "tableTo": "ekklesia_core_device", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device_permission", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["granted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_device_permission_id_check": { + "name": "ekklesia_core_device_permission_id_check", + "value": "length(\"ekklesia_core_device_permission\".\"permission_id\") between 1 and 128" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_user_preference": { + "name": "ekklesia_core_user_preference", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "appearance": { + "name": "appearance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "motion": { + "name": "motion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_user_preference", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_user_preference_appearance_check": { + "name": "ekklesia_core_user_preference_appearance_check", + "value": "\"ekklesia_core_user_preference\".\"appearance\" is null or \"ekklesia_core_user_preference\".\"appearance\" in ('system', 'light', 'dark')" + }, + "ekklesia_core_user_preference_motion_check": { + "name": "ekklesia_core_user_preference_motion_check", + "value": "\"ekklesia_core_user_preference\".\"motion\" is null or \"ekklesia_core_user_preference\".\"motion\" in ('system', 'reduce', 'allow')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/core/drizzle/meta/_ekklesia.json b/core/drizzle/meta/_ekklesia.json index ae1d23a..5ea7fb7 100644 --- a/core/drizzle/meta/_ekklesia.json +++ b/core/drizzle/meta/_ekklesia.json @@ -55,6 +55,12 @@ "destructive": { "mode": "none" } + }, + { + "tag": "0009_platform_display_pairing", + "destructive": { + "mode": "none" + } } ] } diff --git a/core/drizzle/meta/_journal.json b/core/drizzle/meta/_journal.json index 77181d2..e441f35 100644 --- a/core/drizzle/meta/_journal.json +++ b/core/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1792036365485, "tag": "0008_user_preferences", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1792124723290, + "tag": "0009_platform_display_pairing", + "breakpoints": true } ] } diff --git a/core/src/devices/schema.ts b/core/src/devices/schema.ts index a470d28..b720233 100644 --- a/core/src/devices/schema.ts +++ b/core/src/devices/schema.ts @@ -104,12 +104,19 @@ export const ekklesiaCoreDevicePairingRequest = pgTable( id: uuid("id").primaryKey().defaultRandom(), code: text("code").notNull(), claimSecretHash: text("claim_secret_hash").notNull(), - /** The display the device actually navigated to, resolved server-side from the publication - * registry. Never a value the browser chose. */ - moduleId: text("module_id").notNull(), - /** Derived from that module's validated display declaration, so a device cannot ask for a - * permission of its choosing. */ - permissionId: text("permission_id").notNull(), + /** + * The module display the device will show, and the display permission approval grants it, or + * both `null` for a platform pairing (ADR 0065): a screen that opened `/display/setup` and is + * paired as a platform display with no module yet. + * + * A module display page sets both when the request is made, resolved server-side from the + * publication registry and that module's validated display declaration, so a device cannot ask + * for a permission of its choosing. A platform request may have them set once, by the + * approving administrator choosing which installed display module the screen shows; the + * permission is again derived from that module's declaration, never from the request. + */ + moduleId: text("module_id"), + permissionId: text("permission_id"), state: text("state").notNull().default("pending"), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), @@ -140,6 +147,11 @@ export const ekklesiaCoreDevicePairingRequest = pgTable( "ekklesia_core_device_pairing_state_check", sql`${table.state} in ('pending', 'approved', 'consumed')`, ), + // A module and its display permission are set together or not at all. + check( + "ekklesia_core_device_pairing_module_check", + sql`(${table.moduleId} is null) = (${table.permissionId} is null)`, + ), ], ); diff --git a/core/src/devices/store.ts b/core/src/devices/store.ts index 1bdf4d4..ca588f6 100644 --- a/core/src/devices/store.ts +++ b/core/src/devices/store.ts @@ -97,9 +97,13 @@ function generatePairingCode(): string { return randomInt(0, 1_000_000).toString().padStart(6, "0"); } +/** + * A screen asking to be paired. `display` is the module display it opened, with the permission + * derived from that module's validated declaration; it is absent for a platform pairing from + * `/display/setup`, which creates a platform display with no module yet (ADR 0065). + */ export interface CreatePairingRequestInput { - readonly moduleId: string; - readonly permissionId: PermissionId; + readonly display?: { readonly moduleId: string; readonly permissionId: PermissionId }; readonly now: Date; } @@ -114,8 +118,10 @@ export interface CreatedPairingRequest { export type PairingRequestStatus = "pending" | "approved" | "expired" | "unknown"; export interface PairingRequestView { - readonly moduleId: string; - readonly permissionId: string; + /** The module display the screen will show, or `undefined` for a platform pairing that no module + * has been chosen for. */ + readonly moduleId: string | undefined; + readonly permissionId: string | undefined; readonly createdAt: Date; readonly expiresAt: Date; readonly status: "pending" | "approved"; @@ -137,6 +143,13 @@ export interface DeviceSummary { readonly permissions: readonly string[]; } +/** One live device as it sees itself (ADR 0065): its name, and the permissions it holds now. */ +export interface DeviceSelf { + readonly id: EkklesiaDeviceId; + readonly name: string; + readonly permissions: readonly string[]; +} + export interface ResolvedDevice { readonly deviceId: EkklesiaDeviceId; readonly lastSeenAt?: Date; @@ -146,11 +159,18 @@ export interface DeviceStore { createPairingRequest(input: CreatePairingRequestInput): Promise; /** Looks a live request up by the code an administrator typed. Never used by the device itself. */ findPendingByCode(code: string, now: Date): Promise; + /** + * Approves a pending request by its code and names the display. `display`, when given, is the + * module display the administrator chose for a platform request; it is refused (the request is + * left pending and `undefined` returned) for a request that already names a different module, + * and ignored when it names the same one. + */ approvePairingRequest(input: { readonly code: string; readonly deviceName: string; readonly approvedBy: EkklesiaUserId; readonly now: Date; + readonly display?: { readonly moduleId: string; readonly permissionId: PermissionId }; }): Promise; /** The device's own poll. Consumes an approved request atomically and issues the credential. */ claimPairingRequest(input: { @@ -162,6 +182,8 @@ export interface DeviceStore { touchLastSeen(deviceId: EkklesiaDeviceId, now: Date): Promise; hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise; listDevices(): Promise; + /** A live, unrevoked device's own name and active permissions, or `undefined`. */ + describeDevice(deviceId: EkklesiaDeviceId): Promise; revokeDevice(input: { readonly deviceId: EkklesiaDeviceId; readonly revokedBy: EkklesiaUserId; @@ -201,6 +223,11 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore { try { // Conditional on `pending` and unexpired, so approving twice, approving an expired request, // or approving one already consumed all affect zero rows rather than racing. + // + // A module chosen for a platform request is written in the same statement, and only where + // the request names no module yet or already names that one, so a module display's own + // request can never be redirected to a different module's permission. + const chosen = input.display; const rows = await db .update(ekklesiaCoreDevicePairingRequest) .set({ @@ -208,12 +235,23 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore { approvedByUserId: input.approvedBy, deviceName: input.deviceName, state: "approved", + ...(chosen === undefined + ? {} + : { moduleId: chosen.moduleId, permissionId: chosen.permissionId }), }) .where( and( eq(ekklesiaCoreDevicePairingRequest.code, input.code), eq(ekklesiaCoreDevicePairingRequest.state, "pending"), gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now), + ...(chosen === undefined + ? [] + : [ + or( + isNull(ekklesiaCoreDevicePairingRequest.moduleId), + eq(ekklesiaCoreDevicePairingRequest.moduleId, chosen.moduleId), + ), + ]), ), ) .returning({ @@ -225,7 +263,13 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore { const row = rows[0]; return row === undefined ? undefined - : Object.freeze({ ...row, status: "approved" as const }); + : Object.freeze({ + createdAt: row.createdAt, + expiresAt: row.expiresAt, + moduleId: row.moduleId ?? undefined, + permissionId: row.permissionId ?? undefined, + status: "approved" as const, + }); } catch (error) { rethrowAsStoreError(error); } @@ -297,13 +341,16 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore { throw new DeviceStoreError("DEVICE_STORE_FAILED"); } - // Exactly the one permission the request was created for — never a bundle, never every - // permission the module declares. - await tx.insert(ekklesiaCoreDevicePermission).values({ - deviceId, - grantedByUserId: request.approvedByUserId, - permissionId: request.permissionId, - }); + // Exactly the one permission the request carries — never a bundle, never every + // permission the module declares — and none at all for a platform display no module was + // chosen for: it is paired, and simply shows nothing yet (ADR 0065). + if (request.permissionId !== null) { + await tx.insert(ekklesiaCoreDevicePermission).values({ + deviceId, + grantedByUserId: request.approvedByUserId, + permissionId: request.permissionId, + }); + } await tx .update(ekklesiaCoreDevicePairingRequest) @@ -350,8 +397,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore { claimSecretHash: hashDeviceSecret(claimSecret), code, expiresAt, - moduleId: input.moduleId, - permissionId: input.permissionId, + moduleId: input.display?.moduleId ?? null, + permissionId: input.display?.permissionId ?? null, }); return Object.freeze({ claimSecret, code, expiresAt }); } catch { @@ -386,8 +433,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore { : Object.freeze({ createdAt: row.createdAt, expiresAt: row.expiresAt, - moduleId: row.moduleId, - permissionId: row.permissionId, + moduleId: row.moduleId ?? undefined, + permissionId: row.permissionId ?? undefined, status: "pending" as const, }); } catch (error) { @@ -421,6 +468,38 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore { } }, + async describeDevice(deviceId): Promise { + try { + const devices = await db + .select({ id: ekklesiaCoreDevice.id, name: ekklesiaCoreDevice.name }) + .from(ekklesiaCoreDevice) + .where(and(eq(ekklesiaCoreDevice.id, deviceId), isNull(ekklesiaCoreDevice.revokedAt))) + .limit(1); + const device = devices[0]; + if (device === undefined) { + return undefined; + } + const permissions = await db + .select({ permissionId: ekklesiaCoreDevicePermission.permissionId }) + .from(ekklesiaCoreDevicePermission) + .where( + and( + eq(ekklesiaCoreDevicePermission.deviceId, deviceId), + isNull(ekklesiaCoreDevicePermission.revokedAt), + ), + ); + return Object.freeze({ + id: device.id as EkklesiaDeviceId, + name: device.name, + permissions: Object.freeze( + [...new Set(permissions.map((row) => row.permissionId))].sort(), + ), + }); + } catch (error) { + rethrowAsStoreError(error); + } + }, + async listDevices(): Promise { try { const devices = await db diff --git a/core/src/modules/reserved-ids.ts b/core/src/modules/reserved-ids.ts index f5905dd..111509c 100644 --- a/core/src/modules/reserved-ids.ts +++ b/core/src/modules/reserved-ids.ts @@ -1,9 +1,9 @@ /** - * Names the platform owns, which therefore can never be a module id (ADR 0057, ADR 0061). + * Names the platform owns, which therefore can never be a module id (ADR 0057, ADR 0061, ADR 0065). * * A module id is a **stable URL identifier**. Every module with a Dashboard UI is addressed at - * `/`, and every module with settings at `/settings/`, beside the platform's - * own pages in the same two namespaces. A module called `settings` would collide with the + * `/`, every module with settings at `/settings/`, and every module with a + * display at `/display/`, beside the platform's own pages in the same three 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. */ @@ -39,6 +39,13 @@ export const RESERVED_SETTINGS_NAMES = Object.freeze([ "system", ] as const); +/** + * The display host's own pages (`/display/`): `setup`, where a new screen starts pairing as a + * platform display before any module is chosen for it (ADR 0065). Every other `/display/` is a + * module's display. + */ +export const RESERVED_DISPLAY_HOST_NAMES = Object.freeze(["setup"] 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 @@ -46,7 +53,7 @@ export const RESERVED_SETTINGS_NAMES = Object.freeze([ * 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(), + [...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES, ...RESERVED_DISPLAY_HOST_NAMES].sort(), ); const reserved: ReadonlySet = new Set(RESERVED_MODULE_IDS); diff --git a/core/tests/account-store.integration.ts b/core/tests/account-store.integration.ts index 4dc981e..2670b1e 100644 --- a/core/tests/account-store.integration.ts +++ b/core/tests/account-store.integration.ts @@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0006_device_identity", "core:0007_verified_bootstrap_owners", "core:0008_user_preferences", + "core:0009_platform_display_pairing", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/authorization-store.integration.ts b/core/tests/authorization-store.integration.ts index 9f3a79f..442190f 100644 --- a/core/tests/authorization-store.integration.ts +++ b/core/tests/authorization-store.integration.ts @@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0006_device_identity", "core:0007_verified_bootstrap_owners", "core:0008_user_preferences", + "core:0009_platform_display_pairing", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/dashboard-store.integration.ts b/core/tests/dashboard-store.integration.ts index 5af6c9f..fd15f4b 100644 --- a/core/tests/dashboard-store.integration.ts +++ b/core/tests/dashboard-store.integration.ts @@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0006_device_identity", "core:0007_verified_bootstrap_owners", "core:0008_user_preferences", + "core:0009_platform_display_pairing", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/device-pairing-schema.test.ts b/core/tests/device-pairing-schema.test.ts new file mode 100644 index 0000000..b0222f6 --- /dev/null +++ b/core/tests/device-pairing-schema.test.ts @@ -0,0 +1,53 @@ +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 { ekklesiaCoreDevicePairingRequest } from "../src/devices/index.js"; + +/** + * A pairing request may name no module (ADR 0065): a screen on the platform's `/display/setup` + * pairs as a platform display, and a module is chosen for it, if at all, when it is approved. + */ +const migrationsDirectory = path.join( + path.dirname(path.dirname(fileURLToPath(import.meta.url))), + "drizzle", +); + +describe("platform display pairing requests", () => { + it("lets a request name no module, but never a module without its permission", () => { + const config = getTableConfig(ekklesiaCoreDevicePairingRequest); + for (const name of ["module_id", "permission_id"]) { + expect(config.columns.find((column) => column.name === name)?.notNull, name).toBe(false); + } + expect(config.checks.map((check) => check.name)).toContain( + "ekklesia_core_device_pairing_module_check", + ); + }); + + it("is migrated by relaxing two columns and adding the both-or-neither check, nothing else", async () => { + const sql = await readFile( + path.join(migrationsDirectory, "0009_platform_display_pairing.sql"), + "utf8", + ); + const statements = sql + .split("--> statement-breakpoint") + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); + expect(statements).toEqual([ + 'ALTER TABLE "ekklesia_core_device_pairing_request" ALTER COLUMN "module_id" DROP NOT NULL;', + 'ALTER TABLE "ekklesia_core_device_pairing_request" ALTER COLUMN "permission_id" DROP NOT NULL;', + 'ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_module_check" CHECK (("ekklesia_core_device_pairing_request"."module_id" is null) = ("ekklesia_core_device_pairing_request"."permission_id" is null));', + ]); + // Existing requests, which all name a module, satisfy the new check unchanged. + }); + + 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 === "0009_platform_display_pairing")).toEqual( + { destructive: { mode: "none" }, tag: "0009_platform_display_pairing" }, + ); + }); +}); diff --git a/core/tests/module-configuration-store.integration.ts b/core/tests/module-configuration-store.integration.ts index f2e6179..d101755 100644 --- a/core/tests/module-configuration-store.integration.ts +++ b/core/tests/module-configuration-store.integration.ts @@ -180,6 +180,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0006_device_identity", "core:0007_verified_bootstrap_owners", "core:0008_user_preferences", + "core:0009_platform_display_pairing", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/module-discovery.test.ts b/core/tests/module-discovery.test.ts index 8c73398..c658132 100644 --- a/core/tests/module-discovery.test.ts +++ b/core/tests/module-discovery.test.ts @@ -9,6 +9,7 @@ import { isReservedModuleId, ModuleDiscoveryError, type ModuleDiscoveryIssueCode, + RESERVED_DISPLAY_HOST_NAMES, RESERVED_MODULE_IDS, RESERVED_SETTINGS_NAMES, RESERVED_TOP_LEVEL_NAMES, @@ -301,9 +302,15 @@ describe("module discovery", () => { "displays", "general", "settings", + "setup", "system", ]); - for (const list of [RESERVED_MODULE_IDS, RESERVED_TOP_LEVEL_NAMES, RESERVED_SETTINGS_NAMES]) { + for (const list of [ + RESERVED_MODULE_IDS, + RESERVED_TOP_LEVEL_NAMES, + RESERVED_SETTINGS_NAMES, + RESERVED_DISPLAY_HOST_NAMES, + ]) { expect(Object.isFrozen(list)).toBe(true); } diff --git a/core/tests/module-lifecycle-store.integration.ts b/core/tests/module-lifecycle-store.integration.ts index 68348e0..3789dc0 100644 --- a/core/tests/module-lifecycle-store.integration.ts +++ b/core/tests/module-lifecycle-store.integration.ts @@ -183,6 +183,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0006_device_identity", "core:0007_verified_bootstrap_owners", "core:0008_user_preferences", + "core:0009_platform_display_pairing", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md b/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md index a5fcc6a..f3a0e3b 100644 --- a/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md +++ b/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md @@ -3,6 +3,7 @@ - Status: Accepted - Date: 2026-08-24 - Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md) +- Pairing a platform display with no module decided by: [0065](0065-display-setup-phone-forms-and-drawer-scroll-lock.md) - Extends: [0013](0013-core-authorization-and-owner-bootstrap.md), [0032](0032-native-module-realtime-transport.md), [0033](0033-display-kiosk-host-contract.md), diff --git a/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md b/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md index 0cdc5a5..c0dc677 100644 --- a/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md +++ b/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md @@ -1,9 +1,10 @@ -# 0065: Settings → Displays says how to set up a screen; phone forms never zoom; the drawer locks the page +# 0065: Displays pair at the platform's own `/display/setup`; phone forms never zoom; the drawer locks the page - Status: Accepted - Date: 2026-10-16 +- Supersedes, in part: [0036](0036-managed-device-identity-and-pairing.md) (every pairing request + names a module display) - Extends: [0033](0033-display-kiosk-host-contract.md), - [0036](0036-managed-device-identity-and-pairing.md), [0039](0039-shared-design-tokens-and-elements.md), [0045](0045-split-dashboard-and-api-origins.md), [0058](0058-dashboard-shell-navigation-and-lucide-icons.md), @@ -15,8 +16,7 @@ A second hand review on a real iPhone, after ADR 0064, found three defects: - **Settings → Displays did not say how to set up a screen.** Add display took a pairing code, but nothing on the page told an administrator how to get a physical screen to show one. The only setup - instructions were in Signage's own Displays panel. A platform-owned display has to be set up from - the platform, including on an installation without Signage. + instructions were in Signage's own Displays panel. - **iOS Safari zoomed into the Add display form.** Focusing the pairing code zoomed the page into the dialog, and the page stayed zoomed after the dialog closed. The cause is the shared `.ek-control` font size: `typography.size-sm`, 0.875rem, which is 14px. iOS Safari zooms the page into any @@ -24,58 +24,122 @@ A second hand review on a real iPhone, after ADR 0064, found three defects: - **The page behind the phone drawer still scrolled,** and pulling down behind it triggered the browser's pull-to-refresh. -The display host is already generic. `/display/` is the platform's own page (ADR 0033): it -shows a pairing code, waits for approval, then hosts that module's display artifact. A pairing request -names the module because approval grants exactly that module's display permission (ADR 0036). -However, nothing told an administrator which of these pages exist. ADR 0033's display routes noted: -"There is deliberately no display inventory in this pass. A management UI may need one later." That -management UI now exists. +The deeper problem behind the first defect was architectural. Displays are platform-owned (ROADMAP +§3.3, §5.4), but a screen could only start pairing on a module's display page, `/display/` +(ADR 0033). Every pairing request named a module, the database required it (`module_id` and +`permission_id` were `NOT NULL`), and approval granted exactly that module's display permission (ADR +0036). So an installation without Signage, or any other module with a display, could not set up a +display at all, and the platform's display identity was something only a module could bring into +being. + +The identity itself never needed a module. A device is its own principal, and its permissions live +in their own table: a device with none is already representable. ## Decision -### `GET /api/v1/devices/setup`: where a new screen starts +### A screen starts at the platform's own `/display/setup` -The API answers, for an administrator holding `core.devices.manage`, the platform display page of -every running module that shows content on screens: +`/display/setup` is the platform's own display page. It is served whenever the deployment has device +support, whatever is installed, including no module at all. It is the same inert, module-agnostic +page every display is served. `setup` joins Core's reserved module ids in a third group, +`RESERVED_DISPLAY_HOST_NAMES`, so no module's display can take the address. + +On it, the display shell asks the platform who this screen is (`GET /api/v1/device-pairing/self`, +below): + +- **Not paired, or revoked:** it shows a pairing code. It asks to be paired with no module: + `POST /api/v1/device-pairing/requests` with `{}`. +- **Paired and set to show a module:** it opens that module's display page, `location.replace`, which + takes over exactly as if the screen had opened it directly. +- **Paired with nothing set yet:** it shows "This screen is paired" and its name, and waits. + +While waiting, it asks again every 30 seconds. That keeps its credential alive (ADR 0056: credentials +last while in use), notices revocation, and opens a module as soon as the display holds one. + +### One display identity, with or without a module + +There is no second identity system. A screen paired at `/display/setup` is an ordinary platform +device, in the same tables as any other, with the same credential, cookies, revocation, last-seen, +and listing. It simply holds no module display permission until it is given one. + +Migration `core:0009_platform_display_pairing` lets a pairing request name no module. It relaxes +`module_id` and `permission_id` to nullable, and adds a check that they are set together or not at +all. It is additive, destroys nothing, and every existing request satisfies it unchanged. Like every +migration, an operator applies it explicitly. + +- **Requests.** `{}` creates a platform request. `{ "moduleId": … }` is unchanged: it resolves the + module from the running display registry and derives the permission from its declaration. Any other + key is ignored, as it always was, so a browser can never name a permission. +- **Lookup and approval** answer `"moduleId": null, "permissionId": null` for a platform request. +- **Choosing a module at approval.** Approval accepts an optional `moduleId`: the installed display + module the administrator chose for a platform display to show. It must be a running display. The + permission granted is that module's declared display permission, never one the request names. The + choice is written in the same conditional update as the approval, and only where the request names + no module or already names that one, so a module display's own request can never be redirected to + another module's permission. Claiming grants the request's permission, or none. + +### `GET /api/v1/devices/display-modules`: what a display can show + +This PR first added `GET /api/v1/devices/setup`, which listed each display module's page as a place +to start pairing. With `/display/setup`, setup needs no list, so that endpoint is removed. What remains +is a separate, real need: knowing which modules a display can be set to show. That need is met by +`GET /api/v1/devices/display-modules`: ```json -{ "displays": [{ "moduleId": "signage", "name": "Signage", "url": "/display/signage" }] } +{ "modules": [{ "moduleId": "signage", "name": "Signage", "permissionId": "signage.display" }] } ``` -It reads the same eligible, running display registry as the display routes. It answers nothing to -anyone else: it is never anonymous, so an unpaired screen still cannot enumerate what is installed. -It is `no-store`. The URL is root-relative, and the browser resolves it against the API origin, -because the display host lives there in both topologies (ADR 0045). +It requires `core.devices.manage`, like the device list, is `no-store`, and is never anonymous, so it +is not a way to enumerate what is installed. Settings uses it for the **Show on this screen** choice, +and to say what each paired display shows ("Shows Signage", "Not showing a module yet"). An empty list +is a real answer. -**No module-independent display page is added.** A page that let a screen pair before choosing a -module would need a device to exist without a module permission. That is the ROADMAP §5.4 global -display model, and it needs its own decision about assigning devices to modules. Until then, every -screen pairs through a module's display page, and this endpoint is how the platform names those -pages. +### `GET /api/v1/device-pairing/self`: a display asking about itself -### Setting up a screen is one flow, in one place +This endpoint answers only a device principal. It returns its name, and the module display pages it +holds the permission for: + +```json +{ "name": "Foyer TV", "displays": [{ "moduleId": "signage", "url": "/display/signage" }] } +``` + +A person (even an administrator who opened the page on their own laptop), an unpaired screen, and a +revoked device are refused. It renews the device's cookie exactly as a module display's descriptor +does, and is `no-store`. + +### Settings → Displays: one flow, in one place, whatever is installed Settings → Displays opens with a **Set up a new screen** panel, above the paired displays. Its contents are, in order: 1. one sentence: *Open this address on the new screen, then add it with the code it shows*; -2. the absolute display page, with **Copy link**; +2. the platform's setup address, `https:///display/setup`, with **Copy setup link**; 3. **Add display**, right beneath it. -Each step is directly under the one before, so nobody has to go back up the page. **Add display** -lives here and only here, not in the page header. +Each step is directly under the one before. **Add display** lives here and only here, and it is always +offered, whatever modules are installed. The step-by-step version is folded away in a native +`
`. -The step-by-step version is folded away in a native `
` (*Step-by-step instructions*), for -whoever wants it, instead of a block of text everyone has to read past. With more than one module -that uses displays, each address is labelled with its module. +**Add display** looks the code up, then: -When no installed module uses displays, the panel says so and that one installed from Discover will -appear there. It offers no **Add display**, which could only fail. Nothing names Signage. If the -setup address cannot be read, the panel says so and still offers **Add display**, since a screen may -already be showing its code. The paired list keeps working either way. +- **A screen from `/display/setup`:** "A new screen is waiting to be paired". The administrator + names it, and, if any installed module shows content on screens, may choose **Show on this screen**: + *Nothing yet* (the default) or a module. With no such module, the dialog says the display is paired + now and waits. +- **A screen from a module's own display page:** "A display is asking to show Signage", as before, + with no choice. -**Copy link** reaches the clipboard through the clicked button's own window, never a browser global -(ADR 0058). If the browser cannot copy, the button says so and the address stays selectable. +If the module list cannot be read, the choice is simply not offered and the display pairs showing +nothing yet. **Copy setup link** reaches the clipboard through the clicked button's own window, never +a browser global (ADR 0058). + +### Module display routes are unchanged + +`/display/` keeps working exactly as it did: a screen that opens it directly still pairs +with that module's permission, and released Signage 0.1.5 is unaffected. It is simply no longer the +only way to bring a display into being. A screen paired at `/display/setup` and set to show Signage +holds `signage.display`, exactly what a screen paired at `/display/signage` holds, so Signage 0.1.5 +sees and uses it the same way. ### Text controls are at least 16px on phones and touch screens @@ -121,15 +185,22 @@ unchanged. ## Alternatives -- **Copy Signage's setup address into Settings.** Rejected: it names one module, and it is wrong on an - installation without it. +- **Keep pairing only on module display pages, and list them in Settings.** This was this PR's first + version. Rejected: it made a platform-owned display depend on a module existing, and an + installation with no display module could not set one up at all. - **A public `/display` page listing the modules that use displays.** Rejected: it would tell anyone - on the network what is installed, which ADR 0036 deliberately prevents. -- **Pair first, choose the module later.** That is the right long-term model (ROADMAP §5.4). It is - deferred because it changes how a device's permission is granted, and needs its own decision. -- **Use the Dashboard navigation's `display.url`.** Rejected: the navigation lists only modules with - a Dashboard UI, and filters by module permissions. A display-only module, or one the administrator - cannot open, would be missing. + on the network what is installed, which ADR 0036 deliberately prevents. `/display/setup` lists + nothing; what a screen may show reaches only that screen, after it is paired. +- **A sentinel module (for example a `platform` module id) instead of nullable columns.** Rejected: a + value meaning "no module" in a module column is a second, hidden meaning that every reader must know + about. +- **Leaving assignment for later, so a display from `/display/setup` shows nothing until a later + PR.** Rejected by the maintainer: with Signage installed, the platform's own setup flow would lead to + a screen that could not show Signage, while Signage's page still worked. Choosing at approval + closes the loop with the permission table that already exists. +- **Changing or reassigning a paired display's module after pairing.** Deferred: it needs revocation + and arbitration rules between modules (DESIGN.md §11.6). Today a display's module is chosen once, at + approval, or by the module page it was opened on. - **`maximum-scale=1` or `user-scalable=no`.** Rejected: it removes zoom for people who need it, and hides the cause instead of fixing it. - **16px controls everywhere.** Rejected: desktop does not zoom, and the compact size is the design. @@ -138,9 +209,17 @@ unchanged. ## Consequences -- An administrator can set up a screen from Settings → Displays alone. Released Signage 0.1.5 keeps - its own instructions, which point to the same platform page, until its follow-up issue - (`Zayin/Signage#24`) is resolved. +- Any installation can set up and pair a display at `/display/setup`, with or without a module that + uses displays. A display is a platform resource; a module shows content on a display it is set to + show, rather than creating one. +- Migration `core:0009_platform_display_pairing` must be applied before this version runs. It is + additive and non-destructive. +- Every installation now serves the display host, so a missing display host build fails startup, as a + missing Dashboard build already did. +- `setup` can no longer be a module id. No known module uses it. +- Released Signage 0.1.5 keeps working unchanged. Its own setup text still sends administrators to + "Display devices in the Dashboard sidebar" (`Zayin/Signage#24`). Its own `/display/signage` setup + address still works; Settings now sends administrators to `/display/setup`. - Text fields are 16px on phones and touch screens everywhere `@ekklesia/ui` is used, module UIs included. - A real-iPhone recheck remains necessary. Headless Chromium does not reproduce iOS Safari's diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 27d6784..87aeece 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -44,7 +44,7 @@ ADRs capture durable choices that affect more than one package or phase. - [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted - [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted - [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted -- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted (session lifetime superseded by 0056) +- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted (session lifetime superseded by 0056; platform pairing without a module by 0065) - [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted - [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted - [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted @@ -73,4 +73,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0062: The account area, and appearance preferences that belong to the account](0062-account-area-and-synced-appearance-preferences.md) — Accepted - [0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin](0063-module-navigation-contract-manifest-v9.md) — Accepted - [0064: Discover is its own page, the sidebar is navigation only, Settings opens on an overview, and phones get a sticky header](0064-stage-1-shell-review-corrections.md) — Accepted -- [0065: Settings → Displays says how to set up a screen; phone forms never zoom; the drawer locks the page](0065-display-setup-phone-forms-and-drawer-scroll-lock.md) — Accepted +- [0065: Displays pair at the platform's own `/display/setup`; phone forms never zoom; the drawer locks the page](0065-display-setup-phone-forms-and-drawer-scroll-lock.md) — Accepted diff --git a/docs/development/api.md b/docs/development/api.md index 1a7af76..e78fda3 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -980,25 +980,33 @@ what it is accepting. ## Display device pairing and management Host-owned, and reachable by no module -([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). Two sibling -prefixes: `/api/v1/device-pairing` and `/api/v1/devices`. +([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md), +[ADR 0065](../architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md)). Two +sibling prefixes: `/api/v1/device-pairing` and `/api/v1/devices`. + +A new screen starts at the platform's own display page, `/display/setup`, which every installation +serves, whatever modules are installed. It pairs a **platform display** with no module; the +administrator may choose, when approving it, which installed display module it shows. A screen that +opens a module's display page, `/display/`, still pairs with that module directly. **The screen's side** (no identity required; authority is the HttpOnly claim cookie the first call sets, never the code): | route | purpose | | --- | --- | -| `POST /api/v1/device-pairing/requests` | Body `{moduleId}`. Returns `{code, expiresAt, pollIntervalMs}` and sets `ekklesia_device_pairing`. The permission is read from that module's validated display declaration — a request cannot name one. | +| `POST /api/v1/device-pairing/requests` | Body `{}` from `/display/setup` (a platform display, no module), or `{moduleId}` from a module's display page. Returns `{code, expiresAt, pollIntervalMs}` and sets `ekklesia_device_pairing`. A module's permission is read from its validated display declaration — a request can never name one, and any other key is ignored. | | `POST /api/v1/device-pairing/claims` | Polls. Returns `{status}`; on `paired` it sets `ekklesia_device_session` and clears the pairing cookie. | +| `GET /api/v1/device-pairing/self` | A **paired device** only: `{name, displays: [{moduleId, url}]}`, the module display pages it may show. Renews the device cookie. Anyone else — a person, an unpaired or revoked screen — is `403`. | **The administrator's side** (requires the restricted `core.devices.manage` and an exact trusted `Origin`): | route | purpose | | --- | --- | -| `POST /api/v1/device-pairing/lookups` | Body `{code}`. Shows the module and permission a screen is asking for, before approving. | -| `POST /api/v1/device-pairing/approvals` | Body `{code, deviceName}`. The administrator names the screen; the screen never names itself. | +| `POST /api/v1/device-pairing/lookups` | Body `{code}`. Shows the module and permission a screen is asking for — both `null` for a platform display — before approving. | +| `POST /api/v1/device-pairing/approvals` | Body `{code, deviceName, moduleId?}`. The administrator names the screen; the screen never names itself. `moduleId`, for a platform display, is the running display module it shows; its declared display permission is granted. A module display's own request cannot be given a different module. | | `GET /api/v1/devices` | Paired devices: name, permissions, created, last seen, revoked. Never a credential. | +| `GET /api/v1/devices/display-modules` | `{modules: [{moduleId, name, permissionId}]}`: the running modules a display can be set to show. | | `POST /api/v1/devices/:deviceId/revoke` | `204`. Server-side revocation, checked on every principal resolution. | The pairing code travels in request **bodies**, never a path or query, so it does not land in access @@ -1012,7 +1020,7 @@ instead, which now carries `viewer: {kind: "authenticated", userId} | {kind: "de **Cookies.** `ekklesia_device_session` is `HttpOnly`, `SameSite=Strict`, host-only, `Path=/`, and `Secure` when the identity base URL is https. Deployment note: because it is host-only and `SameSite=Strict`, a display must be served from the same origin as the API — which the platform's -own `/display/` route is. +own `/display/setup` and `/display/` routes are. ## Outbound email diff --git a/docs/development/testing.md b/docs/development/testing.md index f65b663..06fa1fd 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -488,11 +488,27 @@ The second review's corrections ([ADR 0065](../architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md)) are proven here: -- `apps/api/tests/device-pairing.integration.ts` covers `GET /api/v1/devices/setup`. It names each - display page, is `no-store`, and refuses an unpaired or paired screen. -- `surfaces/shell/tests/displays-settings.test.tsx` covers the setup steps on the API origin: Copy - link with and without a clipboard, several modules, none installed, a failed read, and a refused - non-root-relative page. +- `apps/api/tests/device-pairing.integration.ts`, on real PostgreSQL: + - with **zero** modules installed, a screen at `/display/setup` pairs as a platform display with + no permission; + - the display can describe itself, an administrator cannot impersonate it, and revocation works; + - an administrator can choose the module a platform display shows at approval; + - a module display's own request cannot be redirected, and unknown keys and modules are refused. +- `apps/api/tests/module-display-routes.test.ts` serves `/display/setup` and the display host with + zero display modules, and only with device support. +- `core/tests/device-pairing-schema.test.ts` pins migration `0009` to its three additive statements. +- `surfaces/shell/tests/display-setup-app.test.tsx` covers the setup page: + - it pairs with no module and waits, named; + - it opens the module it is set to show; + - it opens a module as soon as one is set; + - it returns to a code when revoked; + - it never pairs on a server failure. +- `surfaces/shell/tests/displays-settings.test.tsx` covers: + - the platform setup address, and **Copy setup link**; + - **Add display** with zero display modules; + - the **Show on this screen** choice; + - a module display's request showing its fixed module; + - pairing still working when the module list cannot be read. - `surfaces/shell/tests/scroll-lock.test.ts` covers: - the root locked and its previous styles restored; - drags outside the drawer cancelled, and drags inside it allowed except past its ends; @@ -503,7 +519,11 @@ proven here: - `surfaces/shell/tests/viewport.test.ts` keeps zoom available on both shell pages. - `packages/ui/tests/tokens.test.ts` holds text controls at 16px or more on phones and touch screens. - `packages/ui/tests/interaction.test.tsx` checks that the focus trap returns focus without scrolling. -- The deploy smoke checks, in real Chromium: +- The deploy smoke checks: + - `/display/setup` and a module-less pairing request through the proxy, in both topologies, so in + the same-origin run with no module at all; + - in real Chromium, a screen in its own browser context at `/display/setup` showing a code, being + added through Settings, and then opening its chosen module, or waiting, named; - the setup steps on the API origin; - the page locked behind the open drawer and released after; - the pairing field at 16px or more on a phone. diff --git a/scripts/lib/deploy-smoke-browser.mjs b/scripts/lib/deploy-smoke-browser.mjs index 9784210..10bc3d8 100644 --- a/scripts/lib/deploy-smoke-browser.mjs +++ b/scripts/lib/deploy-smoke-browser.mjs @@ -600,33 +600,112 @@ check( ), "the sidebar is navigation only, and arriving at Displays focuses no field", ); -// ADR 0065: how to set up a new screen, in one place — the platform display page of every module -// that uses displays, on the API origin where the display host lives, with Add display right under -// it; or a plain note, and no Add display, when no installed module uses displays. +// ADR 0065: how to set up a new screen, in one place — the platform's own /display/setup on the API +// origin, whatever is installed, with Add display right under it. check( await waitFor( - `${selector("display-setup")} && !${selector("display-setup-failed")} && (${selector("display-setup-none")} || (${selector("display-setup")}.contains(${selector("add-display")}) && [...document.querySelectorAll('[data-testid="display-setup-url"]')].every((element) => element.textContent.startsWith(${JSON.stringify(`${apiOrigin}/display/`)}))))`, + `${selector("display-setup")}?.contains(${selector("add-display")}) && ${selector("display-setup-url")}?.textContent === ${JSON.stringify(`${apiOrigin}/display/setup`)}`, "the display setup steps", ), - "Settings → Displays explains how to set up a new screen, from the platform's own display page", + "Settings → Displays sets up a new screen at the platform's own /display/setup, with Add display", ); -const displayModuleInstalled = !(await evaluate(`Boolean(${selector("display-setup-none")})`)); -if (displayModuleInstalled) { - await evaluate(`${selector("add-display")}.click()`); - check( - await waitFor( - `${selector("add-display-dialog")} && document.activeElement === ${selector("device-pairing-code-input")}`, - "the Add display dialog with its code field focused", - ), - "Add display opens a dialog that focuses the pairing code only once asked", - ); - await evaluate(`${selector("add-display-cancel")}.click()`); - await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing"); -} else { - check( - !(await evaluate(`Boolean(${selector("add-display")})`)), - "with no module that uses displays, Settings → Displays offers no Add display that could only fail", - ); +await evaluate(`${selector("add-display")}.click()`); +check( + await waitFor( + `${selector("add-display-dialog")} && document.activeElement === ${selector("device-pairing-code-input")}`, + "the Add display dialog with its code field focused", + ), + "Add display opens a dialog that focuses the pairing code only once asked", +); +await evaluate(`${selector("add-display-cancel")}.click()`); +await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing"); + +// ADR 0065, end to end: a new screen — its own browser context, holding no session, as a real +// screen would — opens the platform's /display/setup, shows a code, and is added through the real +// Settings form. It needs no module: chosen, it opens that module's display by itself; not chosen, +// it shows its name and waits. +{ + const { browserContextId } = await cdp.send("Target.createBrowserContext", {}); + const kiosk = await cdp.send("Target.createTarget", { browserContextId, url: "about:blank" }); + const { sessionId: kioskSession } = await cdp.send("Target.attachToTarget", { + flatten: true, + targetId: kiosk.targetId, + }); + const kioskEvaluate = async (expression) => { + const result = await cdp.send( + "Runtime.evaluate", + { awaitPromise: true, expression, returnByValue: true }, + kioskSession, + ); + return result.exceptionDetails ? undefined : result.result.value; + }; + const kioskWaitFor = async (expression) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await kioskEvaluate(`Boolean(${expression})`).catch(() => false)) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return false; + }; + await cdp.send("Page.navigate", { url: `${apiOrigin}/display/setup` }, kioskSession); + const shown = (await kioskWaitFor( + `/^[0-9]{6}$/.test(${selector("display-pairing-code")}?.textContent ?? "")`, + )) + ? await kioskEvaluate(`${selector("display-pairing-code")}.textContent`) + : undefined; + check(shown !== undefined, "a new screen at /display/setup shows a six-digit pairing code"); + if (shown !== undefined) { + await evaluate(`${selector("add-display")}.click()`); + await waitFor(selector("device-pairing-code-input"), "the Add display code field"); + await evaluate(`(() => { + const input = ${selector("device-pairing-code-input")}; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, ${JSON.stringify(shown)}); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.form.requestSubmit(); + })()`); + check( + await waitFor( + `${selector("device-pairing-found")}?.textContent.includes("A new screen is waiting to be paired")`, + "the platform screen found by its code", + ), + "Add display finds the screen from /display/setup as a new platform display, with no module", + ); + const chosen = await evaluate(`(() => { + const name = ${selector("device-pairing-name")}; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(name, "Smoke platform display"); + name.dispatchEvent(new Event("input", { bubbles: true })); + const shows = ${selector("device-pairing-shows")}; + const option = shows ? [...shows.options].find((candidate) => candidate.value !== "") : undefined; + if (option) { + Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set.call(shows, option.value); + shows.dispatchEvent(new Event("change", { bubbles: true })); + } + return option ? option.value : ""; + })()`); + await waitFor(`!${selector("device-pairing-approve")}?.disabled`, "Pair display enabled"); + await evaluate(`${selector("device-pairing-approve")}.click()`); + check( + await waitFor( + `!${selector("add-display-dialog")} && [...document.querySelectorAll('[data-testid^="device-"]')].some((card) => card.textContent.includes("Smoke platform display"))`, + "the new platform display in the list", + ), + "the paired platform display appears under Paired displays without a reload", + ); + check( + await kioskWaitFor( + chosen === "" + ? `${selector("display-setup-waiting-headline")}?.textContent === "Smoke platform display"` + : `location.pathname === ${JSON.stringify(`/display/${chosen}`)}`, + ), + chosen === "" + ? "with no module to show, the paired screen shows its name and waits" + : "the paired screen opens the module display it was set to show, by itself", + ); + } + await cdp.send("Target.closeTarget", { targetId: kiosk.targetId }); + await cdp.send("Target.disposeBrowserContext", { browserContextId }); } await navigate(`${dashboardOrigin}/settings/discover`); check( @@ -698,20 +777,18 @@ check( "choosing a page from the drawer releases the page lock", ); // ADR 0065: on a phone the pairing code is typed at 16px or more, so iOS Safari does not zoom. -if (displayModuleInstalled) { - await navigate(`${dashboardOrigin}/settings/displays`); - await waitFor(selector("add-display"), "Settings → Displays on a phone"); - await evaluate(`${selector("add-display")}.click()`); - check( - await waitFor( - `${selector("device-pairing-code-input")} && parseFloat(getComputedStyle(${selector("device-pairing-code-input")}).fontSize) >= 16`, - "the pairing code field's size on a phone", - ), - "the pairing code field is at least 16px on a phone, so iOS does not zoom into it", - ); - await evaluate(`${selector("add-display-cancel")}.click()`); - await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing on a phone"); -} +await navigate(`${dashboardOrigin}/settings/displays`); +await waitFor(selector("add-display"), "Settings → Displays on a phone"); +await evaluate(`${selector("add-display")}.click()`); +check( + await waitFor( + `${selector("device-pairing-code-input")} && parseFloat(getComputedStyle(${selector("device-pairing-code-input")}).fontSize) >= 16`, + "the pairing code field's size on a phone", + ), + "the pairing code field is at least 16px on a phone, so iOS does not zoom into it", +); +await evaluate(`${selector("add-display-cancel")}.click()`); +await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing on a phone"); await send("Emulation.setDeviceMetricsOverride", { deviceScaleFactor: 1, height: 1080, diff --git a/scripts/lib/deploy-smoke-client.mjs b/scripts/lib/deploy-smoke-client.mjs index 50336d8..2755696 100644 --- a/scripts/lib/deploy-smoke-client.mjs +++ b/scripts/lib/deploy-smoke-client.mjs @@ -115,6 +115,30 @@ async function edgeChecks() { ); } + // ADR 0065: a new screen can start pairing at the platform's own setup page whatever is + // installed — in the same-origin smoke, with no module at all — and asks to be paired with no + // module, as a platform display. + const setupPage = await fetch(`${apiOrigin}/display/setup`); + const setupHtml = await setupPage.text(); + check( + setupPage.status === 200 && + setupPage.headers.get("cache-control") === "no-store" && + setupHtml.includes('id="ekklesia-display"'), + "GET /display/setup through the proxy is the platform display setup page", + ); + const platformPairing = await fetch(`${apiOrigin}/api/v1/device-pairing/requests`, { + body: "{}", + headers: { "content-type": "application/json", origin: apiOrigin }, + method: "POST", + }); + const platformPairingBody = await platformPairing.json().catch(() => ({})); + check( + platformPairing.status === 201 && + /^[0-9]{6}$/u.test(String(platformPairingBody.code)) && + /HttpOnly/iu.test(platformPairing.headers.get("set-cookie") ?? ""), + "a screen at /display/setup can ask to be paired as a platform display, with no module", + ); + 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"); diff --git a/surfaces/shell/src/browser/DisplaysSettingsPage.tsx b/surfaces/shell/src/browser/DisplaysSettingsPage.tsx index 2fa8a12..7f2e190 100644 --- a/surfaces/shell/src/browser/DisplaysSettingsPage.tsx +++ b/surfaces/shell/src/browser/DisplaysSettingsPage.tsx @@ -11,6 +11,7 @@ import { PageHeader, type PageHeaderBack, Panel, + Select, TextInput, } from "@ekklesia/ui"; import { @@ -25,12 +26,13 @@ import { } from "react"; import { approveDevicePairing, + displaySetupUrl, type EkklesiaHostConfig, type HostDeviceSummary, - type HostDisplaySetup, + type HostDisplayModule, HostProtocolError, listHostDevices, - listHostDisplaySetup, + listHostDisplayModules, lookupDevicePairing, revokeHostDevice, } from "../host-protocol.js"; @@ -40,7 +42,11 @@ import { ShellIcon } from "./shell-icons.js"; /** * Settings → Displays (ADR 0064, ADR 0065): the platform's own administration page for the screens * paired with this installation — how to set up a new screen, the paired displays, adding one with - * the code its screen shows, and revoking one. + * the code its screen shows (and, optionally, which module it shows), and revoking one. + * + * Displays are the platform's, whatever is installed: a new screen opens the platform's own + * `/display/setup`, so it can be paired with no module at all, and a module later shows content on a + * display it is set to show rather than creating one. * * It is the device surface that used to sit in the sidebar, moved rather than rebuilt: the same * platform-owned identity, the same endpoints, and the same rules (ADR 0036). One record per @@ -58,16 +64,22 @@ type ListState = | { readonly kind: "denied" } | { readonly kind: "failed" }; -type SetupState = - | { readonly kind: "loading" } - | { readonly kind: "ready"; readonly displays: readonly HostDisplaySetup[] } - | { readonly kind: "failed" }; +/** The modules a display can be set to show, or `undefined` while unknown (loading or failed). */ +type DisplayModules = readonly HostDisplayModule[] | undefined; +/** + * Where the Add display flow is. Once a screen is found, `asked` is the module display it asked + * from, or `undefined` for a platform display from `/display/setup`, which the administrator may set + * to show one of the installed display modules. + */ type PairingStep = | { readonly step: "code"; readonly error?: string } | { readonly step: "finding" } - | { readonly step: "found"; readonly moduleId: string; readonly error?: string } - | { readonly step: "approving"; readonly moduleId: string }; + | { readonly step: "found"; readonly asked: string | undefined; readonly error?: string } + | { readonly step: "approving"; readonly asked: string | undefined }; + +/** The "Nothing yet" choice: a platform display that shows nothing until a module is set for it. */ +const NO_MODULE = ""; export interface DisplaysSettingsPageProps { readonly config: EkklesiaHostConfig; @@ -79,13 +91,17 @@ export interface DisplaysSettingsPageProps { readonly now?: () => Date; } -/** The "Add display" flow: the code from the screen, then the display's one name, then approval. */ +/** + * The "Add display" flow: the code from the screen, then the display's one name — and, for a screen + * set up at `/display/setup`, which installed module it shows, or nothing yet — then approval. + */ function AddDisplayDialog(props: { readonly config: EkklesiaHostConfig; readonly open: boolean; + readonly modules: DisplayModules; readonly moduleName: (moduleId: string) => string | undefined; readonly onDismiss: () => void; - readonly onPaired: (name: string) => void; + readonly onPaired: (name: string, shows: string | undefined) => void; }): ReactElement { const { config, onPaired } = props; const formId = useId(); @@ -93,6 +109,7 @@ function AddDisplayDialog(props: { const nameRef = useRef(null); const [code, setCode] = useState(""); const [name, setName] = useState(""); + const [shows, setShows] = useState(NO_MODULE); const [pairing, setPairing] = useState({ step: "code" }); // A fresh flow every time it opens. @@ -100,6 +117,7 @@ function AddDisplayDialog(props: { if (props.open) { setCode(""); setName(""); + setShows(NO_MODULE); setPairing({ step: "code" }); } }, [props.open]); @@ -120,7 +138,7 @@ function AddDisplayDialog(props: { setPairing({ step: "finding" }); try { const lookup = await lookupDevicePairing(config, code.trim()); - setPairing({ moduleId: lookup.moduleId, step: "found" }); + setPairing({ asked: lookup.moduleId, step: "found" }); } catch { // One message for expired, consumed, mistyped, and never-existed alike. setPairing({ @@ -133,23 +151,27 @@ function AddDisplayDialog(props: { } if (pairing.step === "found") { const displayName = name.trim(); - setPairing({ moduleId: pairing.moduleId, step: "approving" }); + const asked = pairing.asked; + // A screen that asked from a module's own display already shows that module; a platform + // display shows what the administrator chose, if anything. + const chosen = asked === undefined && shows !== NO_MODULE ? shows : undefined; + setPairing({ asked, step: "approving" }); try { - await approveDevicePairing(config, code.trim(), displayName); - onPaired(displayName); + await approveDevicePairing(config, code.trim(), displayName, chosen); + onPaired(displayName, asked ?? chosen); } catch { setPairing({ + asked, error: "That display could not be approved. Its code may have expired.", - moduleId: pairing.moduleId, step: "found", }); } } }; - const moduleId = - pairing.step === "found" || pairing.step === "approving" ? pairing.moduleId : undefined; - const naming = moduleId !== undefined; + const naming = pairing.step === "found" || pairing.step === "approving"; + const asked = naming ? pairing.asked : undefined; + const modules = props.modules; const error = pairing.step === "code" || pairing.step === "found" ? pairing.error : undefined; return ( {naming ? ( <> -

- A display is asking to show {props.moduleName(moduleId) ?? moduleId}. -

+ {asked !== undefined ? ( +

+ A display is asking to show {props.moduleName(asked) ?? asked}. +

+ ) : ( +

+ A new screen is waiting to be paired. +

+ )} {(control) => ( )} + {asked === undefined && modules !== undefined && modules.length > 0 ? ( + + {(control) => ( + + )} + + ) : undefined} + {asked === undefined && modules !== undefined && modules.length === 0 ? ( +

+ No installed module shows content on screens yet. The display is paired now and + waits, showing its name, until one is. +

+ ) : undefined} ) : undefined} {error === undefined ? undefined : ( @@ -245,20 +302,17 @@ function AddDisplayDialog(props: { ); } -/** One display page to open on a screen, with a way to copy it there. */ -function SetupLink(props: { readonly setup: HostDisplaySetup; readonly labelled: boolean }) { +/** The setup address to open on a screen, with a way to copy it there. */ +function SetupLink(props: { readonly url: string }): ReactElement { const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle"); return ( -
- {props.labelled ? ( - For {props.setup.name} - ) : undefined} +
- {props.setup.url} + {props.url} {copy === "copied" @@ -288,74 +342,47 @@ function SetupLink(props: { readonly setup: HostDisplaySetup; readonly labelled: } /** - * Setting up a new screen, start to finish, in one place (ADR 0065): the address to open on the - * screen, then **Add display** to enter the code it shows — so the next step is always right under - * the one before, never back up the page. One sentence says what to do; the step-by-step version is - * there for whoever wants it, folded away. + * Setting up a new screen, start to finish, in one place (ADR 0065): the platform's own setup + * address to open on the screen, then **Add display** to enter the code it shows — so the next step + * is always right under the one before, never back up the page. One sentence says what to do; the + * step-by-step version is there for whoever wants it, folded away. * - * It is platform setup, not any module's: each address is the platform's own display page for a - * module that shows content on screens, as the server lists them. With no such module installed it - * says so, and offers no Add display that could only fail. If the addresses cannot be read, Add - * display is still offered, since a screen may already be showing its code. + * It is the same whatever is installed. The address is `/display/setup` on the API origin, which + * pairs a platform display with no module; which module it shows, if any, is a choice made when it + * is approved. */ function DisplaySetupPanel(props: { - readonly setup: SetupState; + readonly url: string; readonly onAdd: (event: MouseEvent) => void; }): ReactElement { - const { setup } = props; - const none = setup.kind === "ready" && setup.displays.length === 0; - const several = setup.kind === "ready" && setup.displays.length > 1; return (
- {setup.kind === "loading" ? : undefined} - {setup.kind === "failed" ? ( -

- The setup address couldn't be loaded. Reload the page to try again. -

- ) : undefined} - {none ? ( -

- No installed module shows content on screens yet. Once one is installed from Discover, - its setup address appears here. -

- ) : undefined} - {setup.kind === "ready" && !none ? ( - <> -

- Open {several ? "an address" : "this address"} on the new screen, then add it with the - code it shows. -

- {setup.displays.map((display) => ( - - ))} - - ) : undefined} - {none || setup.kind === "loading" ? undefined : ( -
- -
- )} - {setup.kind === "ready" && !none ? ( -
- Step-by-step instructions -
    -
  1. - On the screen, open the address above in its web browser. Opening it on this device - only previews it. -
  2. -
  3. The screen shows a six-digit pairing code.
  4. -
  5. - Choose Add display, enter the code, and give the screen the name - everyone will know it by. -
  6. -
  7. It appears under Paired displays once it's approved.
  8. -
-
- ) : undefined} +

+ Open this address on the new screen, then add it with the code it shows. +

+ +
+ +
+
+ Step-by-step instructions +
    +
  1. + On the screen, open the address above in its web browser. Opening it on this device + only previews it. +
  2. +
  3. The screen shows a six-digit pairing code.
  4. +
  5. + Choose Add display, enter the code, and give the screen the name + everyone will know it by. You can also choose what it shows. +
  6. +
  7. It appears under Paired displays once it's approved.
  8. +
+
); @@ -364,10 +391,21 @@ function DisplaySetupPanel(props: { function DisplayCard(props: { readonly device: HostDeviceSummary; readonly now: Date; + readonly modules: DisplayModules; readonly onRevoke: () => void; }): ReactElement { const { device, now } = props; const facts: string[] = []; + if (!device.revoked && props.modules !== undefined) { + const shows = props.modules.filter((module) => + device.permissions.includes(module.permissionId), + ); + facts.push( + shows.length === 0 + ? "Not showing a module yet" + : `Shows ${shows.map((module) => module.name).join(", ")}`, + ); + } if (device.revoked) { // The badge says so; nothing about when it was last seen is worth acting on any more. } else if (device.lastSeenAt !== undefined) { @@ -404,9 +442,11 @@ function DisplayCard(props: { export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactElement { const { config } = props; const [list, setList] = useState({ kind: "loading" }); - const [setup, setSetup] = useState({ kind: "loading" }); + const [modules, setModules] = useState(undefined); const [adding, setAdding] = useState(false); - const [paired, setPaired] = useState(undefined); + const [paired, setPaired] = useState< + { readonly name: string; readonly shows: string | undefined } | undefined + >(undefined); /** The display an administrator asked to revoke and has not yet confirmed. */ const [confirming, setConfirming] = useState(undefined); const [revoking, setRevoking] = useState(false); @@ -432,17 +472,15 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle useEffect(() => { const controller = new AbortController(); - listHostDisplaySetup(config, controller.signal).then( - (displays) => { + // What a display can be set to show. If it cannot be read, pairing still works — the display is + // paired showing nothing yet — so a failure here only hides the choice. + listHostDisplayModules(config, controller.signal).then( + (found) => { if (!controller.signal.aborted) { - setSetup({ displays, kind: "ready" }); - } - }, - () => { - if (!controller.signal.aborted) { - setSetup({ kind: "failed" }); + setModules(found); } }, + () => undefined, ); return () => controller.abort(); }, [config]); @@ -459,9 +497,9 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle setConfirming(undefined); }; - const onPaired = (name: string): void => { + const onPaired = (name: string, shows: string | undefined): void => { setAdding(false); - setPaired(name); + setPaired({ name, shows }); // The new display appears without a page refresh (DESIGN.md §11.4). void refresh(); }; @@ -482,7 +520,13 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle {paired === undefined ? undefined : ( - {paired} is paired and can be used by your modules. + {paired.shows === undefined + ? `${paired.name} is paired. It shows its name until a module is set to show on it.` + : `${paired.name} is paired and shows ${ + modules?.find((module) => module.moduleId === paired.shows)?.name ?? + props.moduleName(paired.shows) ?? + paired.shows + }.`} )} @@ -497,7 +541,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle setPaired(undefined); setAdding(true); }} - setup={setup} + url={displaySetupUrl(config)} /> ) : undefined} @@ -539,6 +583,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle setConfirming(device)} /> @@ -551,6 +596,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle void; const LOCKED_PROPERTIES = ["overflow", "overscroll-behavior"] as const; diff --git a/surfaces/shell/src/display/DisplaySetupApp.tsx b/surfaces/shell/src/display/DisplaySetupApp.tsx new file mode 100644 index 0000000..ac91dba --- /dev/null +++ b/surfaces/shell/src/display/DisplaySetupApp.tsx @@ -0,0 +1,141 @@ +import { type ReactElement, useEffect, useState } from "react"; +import { + type EkklesiaHostConfig, + type HostDisplaySelf, + HostProtocolError, + loadDisplaySelf, +} from "../host-protocol.js"; +import { DisplayNotice, PairingScreen } from "./PairingScreen.js"; + +/** + * The platform's own display setup page, `/display/setup` (ADR 0065): where any new screen starts, + * whatever modules are installed. + * + * It asks the platform who this screen is (`/api/v1/device-pairing/self`): + * + * - **Not paired** (or revoked): it shows a pairing code, asking to be paired as a platform display + * with no module. An administrator adds it in Settings → Displays, naming it and, optionally, + * choosing which installed module it shows. + * - **Paired, set to show a module:** it opens that module's display page, which takes over from + * there exactly as a screen that had opened it directly. + * - **Paired, nothing set yet:** it shows its name and waits. It keeps asking, every so often, so it + * opens the module as soon as one is set, notices if it is revoked, and keeps its credential + * alive while it waits (ADR 0056). + * + * The screen never says why something failed: anything other than "not paired" is retried. + */ + +/** `/display/` for the platform's setup page: a reserved module id in Core. */ +export const DISPLAY_SETUP_ROUTE_NAME = "setup"; + +/** How often a paired screen with nothing to show asks again. */ +export const DISPLAY_SETUP_RECHECK_MS = 30_000; + +/** How long to wait before asking again after a failure that was not "not paired". */ +export const DISPLAY_SETUP_RETRY_MS = 5_000; + +type SetupState = + | { readonly status: "loading" } + | { readonly status: "pairing" } + | { readonly status: "waiting"; readonly name: string } + | { readonly status: "opening" } + | { readonly status: "unreachable" }; + +export interface DisplaySetupAppProps { + readonly config: EkklesiaHostConfig; + /** Opens a module display page in place of this one. The entry point supplies + * `location.replace`, so Back on a kiosk never returns here. */ + readonly openDisplay: (url: string) => void; + /** For tests: how often to ask again while paired with nothing to show. */ + readonly recheckMs?: number; + /** For tests: how long to wait after a failure. */ + readonly retryMs?: number; +} + +function isNotPaired(error: unknown): boolean { + return ( + error instanceof HostProtocolError && + (error.code === "HOST_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED") + ); +} + +export function DisplaySetupApp(props: DisplaySetupAppProps): ReactElement { + const { config, openDisplay } = props; + const recheckMs = props.recheckMs ?? DISPLAY_SETUP_RECHECK_MS; + const retryMs = props.retryMs ?? DISPLAY_SETUP_RETRY_MS; + const [state, setState] = useState({ status: "loading" }); + /** Bumped to ask again: after pairing, on a recheck, or after a failure. */ + const [attempt, setAttempt] = useState(0); + + // biome-ignore lint/correctness/useExhaustiveDependencies: `attempt` is the trigger to ask again. + useEffect(() => { + const abort = new AbortController(); + let timer: ReturnType | undefined; + const again = (delay: number): void => { + timer = setTimeout(() => setAttempt((value) => value + 1), delay); + }; + loadDisplaySelf(config, abort.signal).then( + (self: HostDisplaySelf) => { + if (abort.signal.aborted) { + return; + } + const first = self.displays[0]; + if (first !== undefined) { + setState({ status: "opening" }); + openDisplay(first.url); + return; + } + setState({ name: self.name, status: "waiting" }); + again(recheckMs); + }, + (error: unknown) => { + if (abort.signal.aborted) { + return; + } + if (isNotPaired(error)) { + setState({ status: "pairing" }); + return; + } + setState((current) => (current.status === "waiting" ? current : { status: "unreachable" })); + again(retryMs); + }, + ); + return () => { + abort.abort(); + if (timer !== undefined) { + clearTimeout(timer); + } + }; + }, [attempt, config, openDisplay, recheckMs, retryMs]); + + if (state.status === "pairing") { + return ( + setAttempt((value) => value + 1)} + /> + ); + } + if (state.status === "waiting") { + return ( + + ); + } + if (state.status === "unreachable") { + return ( + + ); + } + return ; +} diff --git a/surfaces/shell/src/display/PairingScreen.tsx b/surfaces/shell/src/display/PairingScreen.tsx index 8798fc0..e7739ef 100644 --- a/surfaces/shell/src/display/PairingScreen.tsx +++ b/surfaces/shell/src/display/PairingScreen.tsx @@ -48,12 +48,42 @@ type PairingState = export interface PairingScreenProps { readonly config: EkklesiaHostConfig; - readonly moduleId: string; + /** The module display this screen is on, or `undefined` on the platform's own `/display/setup`, + * which pairs a platform display with no module yet (ADR 0065). */ + readonly moduleId: string | undefined; + /** What the screen tells whoever is setting it up. */ + readonly hint?: string; /** Called once the host has issued this device its session, so the display mounts itself without * anyone touching the television. */ readonly onRetry: () => void; } +/** + * A calm full-screen message in the pairing screen's own style: a title, an optional large line, + * and a hint. For the platform setup page's "paired, nothing to show yet" (ADR 0065). + */ +export function DisplayNotice(props: { + readonly title: string; + readonly headline?: string; + readonly hint?: string; + readonly testId: string; +}): ReactElement { + return ( +
+

{props.title}

+ {props.headline === undefined ? undefined : ( +

+ {props.headline} +

+ )} + {props.hint === undefined ? undefined :

{props.hint}

} +
+ ); +} + export function PairingScreen(props: PairingScreenProps): ReactElement { const { config, moduleId, onRetry } = props; const [state, setState] = useState({ status: "requesting" }); @@ -137,7 +167,8 @@ export function PairingScreen(props: PairingScreenProps): ReactElement { {state.code}

- Enter this code in the Ekklesia dashboard to connect this screen. Waiting for approval… + {props.hint ?? + "Enter this code in the Ekklesia dashboard to connect this screen. Waiting for approval…"}

) : undefined} diff --git a/surfaces/shell/src/display/main.tsx b/surfaces/shell/src/display/main.tsx index 3aaf879..6153835 100644 --- a/surfaces/shell/src/display/main.tsx +++ b/surfaces/shell/src/display/main.tsx @@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client"; import type { EkklesiaHostConfig } from "../host-protocol.js"; import { parseDisplayRoute } from "../host-protocol.js"; import { DisplayApp } from "./DisplayApp.js"; +import { DISPLAY_SETUP_ROUTE_NAME, DisplaySetupApp } from "./DisplaySetupApp.js"; import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js"; /** @@ -70,7 +71,11 @@ createEkklesiaThemeController({ createRoot(container).render( <> - {moduleId ? ( + {moduleId === DISPLAY_SETUP_ROUTE_NAME ? ( + // The platform's own setup page (ADR 0065). `setup` is a reserved module id, so no module's + // display can live at this address. + window.location.replace(url)} /> + ) : moduleId ? ( ) : (
{ const payload = await requestPlatformMutation( config, "/api/v1/device-pairing/requests", - { moduleId }, + moduleId === undefined ? {} : { moduleId }, signal, ); if (!hasExactKeys(payload, ["code", "expiresAt", "pollIntervalMs"])) { @@ -524,11 +526,28 @@ function optionalTimestamp(value: unknown): Date | undefined { return Number.isNaN(date.getTime()) ? undefined : date; } +/** A null-or-string field from the server: the string, `undefined` for `null`, or an error. */ +function nullableString(value: unknown): string | undefined { + if (value === null) { + return undefined; + } + const text = nonEmptyString(value); + if (text === undefined) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return text; +} + +/** + * Looks up a screen waiting to be paired by the code it shows. `moduleId` is the module display it + * asked from, or `undefined` for a platform display from `/display/setup` (ADR 0065), for which the + * administrator may choose a module. + */ export async function lookupDevicePairing( config: EkklesiaHostConfig, code: string, signal?: AbortSignal, -): Promise<{ readonly moduleId: string; readonly permissionId: string }> { +): Promise<{ readonly moduleId: string | undefined; readonly permissionId: string | undefined }> { const payload = await requestPlatformMutation( config, "/api/v1/device-pairing/lookups", @@ -538,24 +557,27 @@ export async function lookupDevicePairing( if (!hasExactKeys(payload, ["expiresAt", "moduleId", "permissionId", "requestedAt"])) { throw new HostProtocolError("HOST_RESPONSE_INVALID"); } - const moduleId = nonEmptyString(payload.moduleId); - const permissionId = nonEmptyString(payload.permissionId); - if (moduleId === undefined || permissionId === undefined) { + const moduleId = nullableString(payload.moduleId); + const permissionId = nullableString(payload.permissionId); + if ((moduleId === undefined) !== (permissionId === undefined)) { throw new HostProtocolError("HOST_RESPONSE_INVALID"); } return Object.freeze({ moduleId, permissionId }); } +/** Approves a waiting screen and names it. `moduleId`, for a platform display, is the installed + * display module the administrator chose for it to show (ADR 0065); omitted, it shows nothing yet. */ export async function approveDevicePairing( config: EkklesiaHostConfig, code: string, deviceName: string, + moduleId?: string, signal?: AbortSignal, ): Promise { await requestPlatformMutation( config, "/api/v1/device-pairing/approvals", - { code, deviceName }, + moduleId === undefined ? { code, deviceName } : { code, deviceName, moduleId }, signal, ); } @@ -622,46 +644,99 @@ export async function revokeHostDevice( } } +/** The root-relative address of the platform's own display setup page (ADR 0065). */ +export const DISPLAY_SETUP_PATH = "/display/setup"; + /** - * Where a new screen starts pairing, for one module that shows content on screens (ADR 0065): its - * name, and the absolute address of the platform display page a screen opens to show its pairing - * code. The address is the API origin's, because the display host lives there in both topologies + * The absolute address a new screen opens to start pairing, whatever is installed: the platform's + * `/display/setup` on the API origin, which is where the display host lives in both topologies * (ADR 0045). */ -export interface HostDisplaySetup { +export function displaySetupUrl(config: EkklesiaHostConfig): string { + return resolveApiUrl(config, DISPLAY_SETUP_PATH); +} + +/** One installed module that shows content on screens: what a display can be set to show. */ +export interface HostDisplayModule { readonly moduleId: string; readonly name: string; - readonly url: string; + /** The display permission a device needs to show it; a paired display holding it shows it. */ + readonly permissionId: string; } /** - * The display pages a new screen can open (`GET /api/v1/devices/setup`), for Settings → Displays. - * Requires `core.devices.manage`, like the device list. An empty list means no installed module - * uses displays yet. + * The modules a display can be set to show (`GET /api/v1/devices/display-modules`, ADR 0065), for + * the Add display choice and for naming what each paired display shows. Requires + * `core.devices.manage`. An empty list means no installed module uses displays yet. */ -export async function listHostDisplaySetup( +export async function listHostDisplayModules( config: EkklesiaHostConfig, signal?: AbortSignal, -): Promise { - const payload = await requestPlatformJson(config, "/api/v1/devices/setup", signal, { +): Promise { + const payload = await requestPlatformJson(config, "/api/v1/devices/display-modules", signal, { forbidden: "HOST_ACCESS_DENIED", }); - if (!hasExactKeys(payload, ["displays"]) || !Array.isArray(payload.displays)) { + if (!hasExactKeys(payload, ["modules"]) || !Array.isArray(payload.modules)) { throw new HostProtocolError("HOST_RESPONSE_INVALID"); } return Object.freeze( - payload.displays.map((entry) => { + payload.modules.map((entry) => { const moduleId = nonEmptyString((entry as { moduleId?: unknown }).moduleId); const name = nonEmptyString((entry as { name?: unknown }).name); - const path = rootRelativePath(entry, "url"); - if (!hasExactKeys(entry, ["moduleId", "name", "url"]) || !moduleId || !name || !path) { + const permissionId = nonEmptyString((entry as { permissionId?: unknown }).permissionId); + if ( + !hasExactKeys(entry, ["moduleId", "name", "permissionId"]) || + !moduleId || + !name || + !permissionId + ) { throw new HostProtocolError("HOST_RESPONSE_INVALID"); } - return Object.freeze({ moduleId, name, url: resolveApiUrl(config, path) }); + return Object.freeze({ moduleId, name, permissionId }); }), ); } +/** A paired display as it sees itself (ADR 0065): its name, and the module displays it may show. */ +export interface HostDisplaySelf { + readonly name: string; + readonly displays: readonly { readonly moduleId: string; readonly url: string }[]; +} + +/** + * Asks the platform who this paired display is (`GET /api/v1/device-pairing/self`). Answered only + * for a device; anyone else — an unpaired or revoked screen, or a person — is `HOST_ACCESS_DENIED`. + * Each display's `url` is the root-relative module display page, on this same origin. + */ +export async function loadDisplaySelf( + config: EkklesiaHostConfig, + signal?: AbortSignal, +): Promise { + const payload = await requestPlatformJson(config, "/api/v1/device-pairing/self", signal, { + forbidden: "HOST_ACCESS_DENIED", + }); + if (!hasExactKeys(payload, ["displays", "name"]) || !Array.isArray(payload.displays)) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + const name = nonEmptyString(payload.name); + if (name === undefined) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return Object.freeze({ + displays: Object.freeze( + payload.displays.map((entry) => { + const moduleId = nonEmptyString((entry as { moduleId?: unknown }).moduleId); + const url = rootRelativePath(entry, "url"); + if (!hasExactKeys(entry, ["moduleId", "url"]) || !moduleId || !url) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return Object.freeze({ moduleId, url }); + }), + ), + name, + }); +} + /** One installed module as module lifecycle reports it (ADR 0028): what it is and whether it runs. */ export interface HostModuleLifecycleSummary { readonly id: string; diff --git a/surfaces/shell/src/index.ts b/surfaces/shell/src/index.ts index 57e6582..a4156dd 100644 --- a/surfaces/shell/src/index.ts +++ b/surfaces/shell/src/index.ts @@ -18,6 +18,7 @@ export { type DashboardNavigationEntry, type DashboardNavigationGroup, type DisplayHostBootstrap, + DISPLAY_SETUP_PATH, type EkklesiaHostConfig, type HostAccount, type HostAccountPreferences, @@ -30,6 +31,7 @@ export { type HostProtocolErrorCode, loadDashboardNavigation, loadDashboardSettings, + loadDisplaySelf, loadHostAccount, loadHostPrincipal, loadModuleDisplay, diff --git a/surfaces/shell/tests/display-setup-app.test.tsx b/surfaces/shell/tests/display-setup-app.test.tsx new file mode 100644 index 0000000..f661f76 --- /dev/null +++ b/surfaces/shell/tests/display-setup-app.test.tsx @@ -0,0 +1,179 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DisplaySetupApp } from "../src/display/DisplaySetupApp.js"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +/** + * The platform's own display setup page, `/display/setup` (ADR 0065): a screen pairs here with no + * module installed at all, then opens whatever module it is set to show — or waits, named, if + * nothing is set yet. + */ + +const apiBaseUrl = "https://ekklesia.example.test"; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); +}); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); +} + +interface Self { + readonly name: string; + readonly displays: readonly { moduleId: string; url: string }[]; +} + +/** + * A fake API. `self` is what `/api/v1/device-pairing/self` answers right now: `undefined` for a + * screen that is not paired (403), or the device as it sees itself. + */ +function api(initial: { self?: Self | "error" } = {}) { + const state: { self: Self | "error" | undefined; claim: "pending" | "paired" } = { + claim: "pending", + self: initial.self, + }; + const requests: { path: string; body: unknown }[] = []; + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = new URL(String(input)).pathname; + const body = init?.body === undefined ? undefined : JSON.parse(String(init.body)); + requests.push({ body, path }); + if (path === "/api/v1/device-pairing/self") { + if (state.self === "error") { + return json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503); + } + return state.self === undefined + ? json({ code: "AUTHORIZATION_DENIED" }, 403) + : json({ displays: state.self.displays, name: state.self.name }); + } + if (path === "/api/v1/device-pairing/requests") { + return json( + { code: "482913", expiresAt: "2026-10-16T12:10:00.000Z", pollIntervalMs: 1000 }, + 201, + ); + } + if (path === "/api/v1/device-pairing/claims") { + return json({ status: state.claim }); + } + return json({ code: "NOT_FOUND" }, 404); + }) as unknown as typeof globalThis.fetch; + return { fetch, requests, state }; +} + +function query(testId: string): HTMLElement | null { + return container.querySelector(`[data-testid="${testId}"]`); +} + +async function flush(ms = 0): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +async function renderSetup(server: ReturnType) { + const openDisplay = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + await flush(); + return openDisplay; +} + +describe("the platform display setup page", () => { + it("pairs a screen with no module at all, then shows it is paired and waiting", async () => { + const server = api(); + const openDisplay = await renderSetup(server); + + // Not paired: it asks to be paired as a platform display — no module — and shows the code. + expect(query("display-pairing-code")?.textContent).toBe("482913"); + expect(server.requests.find((r) => r.path === "/api/v1/device-pairing/requests")?.body).toEqual( + {}, + ); + expect(query("display-pairing")?.textContent).toContain("Settings → Displays → Add display"); + + // An administrator approves it, with nothing for it to show yet. + server.state.claim = "paired"; + server.state.self = { displays: [], name: "Foyer TV" }; + await flush(1000); + await flush(); + + expect(query("display-setup-waiting-headline")?.textContent).toBe("Foyer TV"); + expect(query("display-setup-waiting")?.textContent).toContain("This screen is paired"); + expect(openDisplay).not.toHaveBeenCalled(); + }); + + it("opens the module display a paired screen is set to show", async () => { + const server = api({ + self: { displays: [{ moduleId: "signage", url: "/display/signage" }], name: "Hall" }, + }); + const openDisplay = await renderSetup(server); + + expect(openDisplay).toHaveBeenCalledWith("/display/signage"); + expect(server.requests.some((r) => r.path === "/api/v1/device-pairing/requests")).toBe(false); + }); + + it("keeps asking while it waits, and opens the module as soon as one is set", async () => { + const server = api({ self: { displays: [], name: "Foyer TV" } }); + const openDisplay = await renderSetup(server); + expect(query("display-setup-waiting")).not.toBeNull(); + + server.state.self = { + displays: [{ moduleId: "signage", url: "/display/signage" }], + name: "Foyer TV", + }; + await flush(30_000); + + expect(openDisplay).toHaveBeenCalledWith("/display/signage"); + }); + + it("returns to a pairing code when a waiting screen is revoked", async () => { + const server = api({ self: { displays: [], name: "Foyer TV" } }); + await renderSetup(server); + + server.state.self = undefined; + await flush(30_000); + await flush(); + + expect(query("display-pairing-code")?.textContent).toBe("482913"); + }); + + it("never starts pairing on a server failure, and tries again", async () => { + const server = api({ self: "error" }); + await renderSetup(server); + + expect(query("display-setup-unreachable")).not.toBeNull(); + expect(server.requests.some((r) => r.path === "/api/v1/device-pairing/requests")).toBe(false); + + server.state.self = { displays: [], name: "Foyer TV" }; + await flush(5_000); + expect(query("display-setup-waiting")).not.toBeNull(); + }); +}); diff --git a/surfaces/shell/tests/displays-settings.test.tsx b/surfaces/shell/tests/displays-settings.test.tsx index 7338191..a6f5b68 100644 --- a/surfaces/shell/tests/displays-settings.test.tsx +++ b/surfaces/shell/tests/displays-settings.test.tsx @@ -41,10 +41,16 @@ function json(body: unknown, status = 200): Response { interface ServerOptions { readonly devices?: unknown[]; readonly denied?: boolean; - /** What `GET /api/v1/devices/setup` answers: the display pages, or a failure. */ - readonly setup?: unknown[] | "fails"; + /** What `GET /api/v1/devices/display-modules` answers: the installed display modules (by + * default Signage), none, or a failure. */ + readonly modules?: unknown[] | "fails"; } +/** A screen that asked from Signage's own display page. */ +const MODULE_CODE = "123456"; +/** A screen that asked from the platform's `/display/setup`, with no module (ADR 0065). */ +const PLATFORM_CODE = "654321"; + function server(options: ServerOptions = {}) { let devices: unknown[] = options.devices ?? [ { @@ -61,13 +67,13 @@ function server(options: ServerOptions = {}) { const path = new URL(String(input)).pathname; const body = init?.body === undefined ? undefined : JSON.parse(String(init.body)); requests.push({ body, path }); - if (path === "/api/v1/devices/setup") { - if (options.setup === "fails") { + if (path === "/api/v1/devices/display-modules") { + if (options.modules === "fails") { return json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503); } return json({ - displays: options.setup ?? [ - { moduleId: "signage", name: "Signage", url: "/display/signage" }, + modules: options.modules ?? [ + { moduleId: "signage", name: "Signage", permissionId: "signage.display" }, ], }); } @@ -79,27 +85,35 @@ function server(options: ServerOptions = {}) { return new Response(null, { status: 204 }); } if (path === "/api/v1/device-pairing/lookups") { - return (body as { code: string }).code === "123456" - ? json({ - expiresAt: "2026-10-15T12:10:00.000Z", - moduleId: "signage", - permissionId: "signage.display", - requestedAt: "2026-10-15T11:59:00.000Z", - }) - : json({ code: "DEVICE_PAIRING_NOT_FOUND" }, 404); + const code = (body as { code: string }).code; + if (code !== MODULE_CODE && code !== PLATFORM_CODE) { + return json({ code: "DEVICE_PAIRING_NOT_FOUND" }, 404); + } + const module = code === MODULE_CODE; + return json({ + expiresAt: "2026-10-15T12:10:00.000Z", + moduleId: module ? "signage" : null, + permissionId: module ? "signage.display" : null, + requestedAt: "2026-10-15T11:59:00.000Z", + }); } if (path === "/api/v1/device-pairing/approvals") { + const approval = body as { code: string; deviceName: string; moduleId?: string }; + const shows = approval.code === MODULE_CODE ? "signage" : approval.moduleId; devices = [ ...devices, { createdAt: "2026-10-15T12:00:00.000Z", id: "device-2", - name: (body as { deviceName: string }).deviceName, - permissions: [], + name: approval.deviceName, + permissions: shows === "signage" ? ["signage.display"] : [], revoked: false, }, ]; - return json({ moduleId: "signage", permissionId: "signage.display" }); + return json({ + moduleId: shows ?? null, + permissionId: shows === undefined ? null : `${shows}.display`, + }); } return json({ code: "NOT_FOUND" }, 404); }) as unknown as typeof globalThis.fetch; @@ -140,6 +154,15 @@ async function type(testId: string, value: string): Promise { }); } +async function choose(testId: string, value: string): Promise { + const select = required(testId) as HTMLSelectElement; + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set; + setter?.call(select, value); + select.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + async function submit(testId: string): Promise { const form = (required(testId) as HTMLButtonElement).form; await act(async () => { @@ -173,12 +196,13 @@ describe("Settings → Displays", () => { expect(card.textContent).toContain("Foyer TV"); expect(card.textContent).toContain("Paired"); expect(card.textContent).toContain("Last seen within the last hour"); + expect(card.textContent).toContain("Not showing a module yet"); expect(container.querySelector("h1")?.textContent).toBe("Displays"); - // How to set up a new screen comes first, in one place: the platform display page on the API - // origin, then Add display right under it, with the step-by-step version folded away. + // How to set up a new screen comes first, in one place: the platform's own setup page on the + // API origin, then Add display right under it, with the step-by-step version folded away. const setup = required("display-setup"); expect(setup.textContent).toContain("Open this address on the new screen"); - expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/signage`); + expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`); expect(setup.contains(required("add-display"))).toBe(true); const steps = required("display-setup-steps"); expect(steps.tagName).toBe("DETAILS"); @@ -216,9 +240,11 @@ describe("Settings → Displays", () => { await submit("device-pairing-lookup"); expect(required("device-pairing-error").textContent).toContain("not waiting for approval"); - await type("device-pairing-code-input", "123456"); + await type("device-pairing-code-input", MODULE_CODE); await submit("device-pairing-lookup"); + // A screen that asked from a module's own display already shows that module: no choice. expect(required("device-pairing-found").textContent).toContain("Signage"); + expect(query("device-pairing-shows")).toBeNull(); expect(document.activeElement).toBe(required("device-pairing-name")); await type("device-pairing-name", "Sanctuary left"); @@ -227,10 +253,13 @@ describe("Settings → Displays", () => { expect(query("add-display-dialog")).toBeNull(); expect( platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body, - ).toEqual({ code: "123456", deviceName: "Sanctuary left" }); + ).toEqual({ code: MODULE_CODE, deviceName: "Sanctuary left" }); // The new display appears without a page refresh, and the page says so. expect(required("device-device-2").textContent).toContain("Sanctuary left"); - expect(required("device-pairing-success").textContent).toContain("Sanctuary left is paired"); + expect(required("device-pairing-success").textContent).toContain( + "Sanctuary left is paired and shows Signage.", + ); + expect(required("device-device-2").textContent).toContain("Shows Signage"); // Focus is back on the control that opened the flow. expect(document.activeElement).toBe(required("add-display")); }); @@ -243,67 +272,99 @@ describe("Settings → Displays", () => { expect(query("add-display-dialog")).toBeNull(); expect(platform.requests.map((r) => r.path).sort()).toEqual([ "/api/v1/devices", - "/api/v1/devices/setup", + "/api/v1/devices/display-modules", ]); expect(document.activeElement).toBe(required("add-display")); }); }); describe("setting up a new screen (ADR 0065)", () => { - it("copies the setup address, and says so when the browser cannot", async () => { + it("copies the platform setup address, and says so when the browser cannot", async () => { const writeText = vi.fn(async () => undefined); Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: { writeText }, }); await renderPage(); - await click("display-setup-copy-signage"); - expect(writeText).toHaveBeenCalledWith(`${apiBaseUrl}/display/signage`); + await click("display-setup-copy"); + expect(writeText).toHaveBeenCalledWith(`${apiBaseUrl}/display/setup`); expect(required("display-setup").textContent).toContain("Link copied."); Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: undefined }); - await click("display-setup-copy-signage"); + await click("display-setup-copy"); expect(required("display-setup").textContent).toContain("couldn't copy it"); }); - it("names each module's page when more than one module uses displays", async () => { - await renderPage({ - setup: [ - { moduleId: "signage", name: "Signage", url: "/display/signage" }, - { moduleId: "parent-call", name: "Parent Call", url: "/display/parent-call" }, - ], - }); - expect(required("display-setup-signage").textContent).toContain("For Signage"); - expect(required("display-setup-parent-call").textContent).toContain( - `For Parent Call${apiBaseUrl}/display/parent-call`, - ); - }); + it("offers the same setup and Add display with zero display modules installed", async () => { + const platform = await renderPage({ modules: [] }); - it("makes sense with no module that uses displays installed, Signage included", async () => { - await renderPage({ setup: [] }); - expect(required("display-setup-none").textContent).toContain( + // The generic platform address, and nothing that names a module. + expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`); + expect(required("display-setup").textContent).not.toContain("Signage"); + expect(query("add-display")).not.toBeNull(); + + // A screen from /display/setup pairs as a platform display with no module. + await click("add-display"); + await type("device-pairing-code-input", PLATFORM_CODE); + await submit("device-pairing-lookup"); + expect(required("device-pairing-found").textContent).toContain( + "A new screen is waiting to be paired", + ); + expect(query("device-pairing-shows")).toBeNull(); + expect(required("device-pairing-no-modules").textContent).toContain( "No installed module shows content on screens yet", ); - expect(query("display-setup-url")).toBeNull(); - // No Add display that could only fail, and nothing names Signage. - expect(query("add-display")).toBeNull(); - expect(required("display-setup").textContent).not.toContain("Signage"); + await type("device-pairing-name", "Foyer TV 2"); + await submit("device-pairing-approve"); + + expect( + platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body, + ).toEqual({ code: PLATFORM_CODE, deviceName: "Foyer TV 2" }); + expect(required("device-pairing-success").textContent).toContain( + "Foyer TV 2 is paired. It shows its name until a module is set to show on it.", + ); + expect(required("device-device-2").textContent).toContain("Not showing a module yet"); }); - it("keeps the rest of the page working when the setup address cannot be read", async () => { - await renderPage({ setup: "fails" }); - expect(required("display-setup-failed").textContent).toContain("couldn't be loaded"); - expect(required("device-device-1").textContent).toContain("Foyer TV"); + it("lets the administrator choose which installed module a new platform display shows", async () => { + const platform = await renderPage(); await click("add-display"); - expect(query("add-display-dialog")).not.toBeNull(); + await type("device-pairing-code-input", PLATFORM_CODE); + await submit("device-pairing-lookup"); + + const shows = required("device-pairing-shows") as HTMLSelectElement; + expect([...shows.options].map((option) => option.textContent)).toEqual([ + "Nothing yet", + "Signage", + ]); + expect(shows.value).toBe(""); + await choose("device-pairing-shows", "signage"); + await type("device-pairing-name", "Lobby screen"); + await submit("device-pairing-approve"); + + expect( + platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body, + ).toEqual({ code: PLATFORM_CODE, deviceName: "Lobby screen", moduleId: "signage" }); + expect(required("device-pairing-success").textContent).toContain( + "Lobby screen is paired and shows Signage.", + ); + expect(required("device-device-2").textContent).toContain("Shows Signage"); }); - it("refuses a setup answer that is not a plain root-relative page", async () => { - await renderPage({ - setup: [{ moduleId: "signage", name: "Signage", url: "https://elsewhere.example/display" }], - }); - expect(query("display-setup-failed")).not.toBeNull(); - expect(query("display-setup-url")).toBeNull(); + it("still pairs, showing nothing yet, when the display modules cannot be read", async () => { + const platform = await renderPage({ modules: "fails" }); + expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`); + await click("add-display"); + await type("device-pairing-code-input", PLATFORM_CODE); + await submit("device-pairing-lookup"); + expect(query("device-pairing-shows")).toBeNull(); + await type("device-pairing-name", "Hall"); + await submit("device-pairing-approve"); + expect( + platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body, + ).toEqual({ code: PLATFORM_CODE, deviceName: "Hall" }); + // Without the module list the card does not guess what the display shows. + expect(required("device-device-1").textContent).not.toContain("showing"); }); }); diff --git a/surfaces/shell/tests/settings-navigation.test.tsx b/surfaces/shell/tests/settings-navigation.test.tsx index bc6a16e..c1c6499 100644 --- a/surfaces/shell/tests/settings-navigation.test.tsx +++ b/surfaces/shell/tests/settings-navigation.test.tsx @@ -156,10 +156,10 @@ async function render( // The existing data the Settings overview reads (ADR 0064); a 403 for anyone it would refuse. const visible = options.settings ?? ADMINISTRATOR; const sections = visible === "fails" ? ADMINISTRATOR.sections : visible.sections; - if (url.endsWith("/api/v1/devices/setup")) { + if (url.endsWith("/api/v1/devices/display-modules")) { return sections.includes("displays") ? Response.json({ - displays: [{ moduleId: "signage", name: "Signage", url: "/display/signage" }], + modules: [{ moduleId: "signage", name: "Signage", permissionId: "signage.display" }], }) : Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); }