Pair displays at the platform's own /display/setup, with or without modules
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 45s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m34s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m52s

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/<module-id> is unchanged, so released Signage 0.1.5 works as
  before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-16 16:43:02 -05:00
co-authored by Claude Opus 5.5
parent 906c015ca8
commit 300d9eab51
44 changed files with 3169 additions and 396 deletions
+3 -2
View File
@@ -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.
+3 -2
View File
@@ -955,8 +955,9 @@ links to where each is changed; the platform's General, Branding, Displays, Disc
each module's settings at `/settings/<module-id>`. 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
+6 -4
View File
@@ -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),
}
+123 -33
View File
@@ -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/<module-id>` (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<ReturnType<typeof options.runtime.resolvePrincipal>>;
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();
+7
View File
@@ -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`;
+38 -4
View File
@@ -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);
}
+4 -3
View File
@@ -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<ReturnType<typeof loadDisplayHostAssets>> | undefined;
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
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
+1
View File
@@ -261,6 +261,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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",
+16 -1
View File
@@ -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/<module-id> 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)", () => {
+221 -24
View File
@@ -99,6 +99,12 @@ async function query<T extends QueryResultRow>(
/** 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<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-empty-"));
trackedDirectories.push(directory);
return directory;
}
async function createDisplayModuleDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-"));
trackedDirectories.push(directory);
@@ -267,9 +273,17 @@ interface Platform {
readonly close: () => Promise<void>;
}
async function platform(label: string): Promise<Platform> {
async function platform(
label: string,
options: { readonly modules?: boolean } = {},
): Promise<Platform> {
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();
}
});
});
+14 -2
View File
@@ -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<typeof import("../src/display-host-assets.js")>();
defaultDisplayHostAssets.factory = () =>
actual.createDisplayHostAssets("<!doctype html>", "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();
});
@@ -250,6 +250,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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<void> {
"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(
+47 -1
View File
@@ -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,
);
});
});
@@ -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));
File diff suppressed because it is too large Load Diff
+6
View File
@@ -55,6 +55,12 @@
"destructive": {
"mode": "none"
}
},
{
"tag": "0009_platform_display_pairing",
"destructive": {
"mode": "none"
}
}
]
}
+7
View File
@@ -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
}
]
}
+18 -6
View File
@@ -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)`,
),
],
);
+95 -16
View File
@@ -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<CreatedPairingRequest>;
/** Looks a live request up by the code an administrator typed. Never used by the device itself. */
findPendingByCode(code: string, now: Date): Promise<PairingRequestView | undefined>;
/**
* 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<PairingRequestView | undefined>;
/** 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<void>;
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
listDevices(): Promise<readonly DeviceSummary[]>;
/** A live, unrevoked device's own name and active permissions, or `undefined`. */
describeDevice(deviceId: EkklesiaDeviceId): Promise<DeviceSelf | undefined>;
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<DeviceSelf | undefined> {
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<readonly DeviceSummary[]> {
try {
const devices = await db
+11 -4
View File
@@ -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
* `/<module-id>`, and every module with settings at `/settings/<module-id>`, beside the platform's
* own pages in the same two namespaces. A module called `settings` would collide with the
* `/<module-id>`, every module with settings at `/settings/<module-id>`, and every module with a
* display at `/display/<module-id>`, 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/<name>`): `setup`, where a new screen starts pairing as a
* platform display before any module is chosen for it (ADR 0065). Every other `/display/<name>` 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<string> = new Set(RESERVED_MODULE_IDS);
+1
View File
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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 {
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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 {
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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 {
+53
View File
@@ -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" },
);
});
});
@@ -180,6 +180,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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 {
+8 -1
View File
@@ -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);
}
@@ -183,6 +183,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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 {
@@ -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),
@@ -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/<module-id>` 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/<module-id>`
(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://<api-origin>/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
`<details>`.
The step-by-step version is folded away in a native `<details>` (*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/<module-id>` 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
+2 -2
View File
@@ -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
+14 -6
View File
@@ -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/<module-id>`, 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/<module-id>` route is.
own `/display/setup` and `/display/<module-id>` routes are.
## Outbound email
+26 -6
View File
@@ -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.
+113 -36
View File
@@ -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,
+24
View File
@@ -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");
@@ -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<HTMLInputElement>(null);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [shows, setShows] = useState<string>(NO_MODULE);
const [pairing, setPairing] = useState<PairingStep>({ 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 (
<Dialog
@@ -185,7 +207,7 @@ function AddDisplayDialog(props: {
description={
naming
? "Give it the name everyone will know it by. Every module that uses this screen shows the same name."
: "Open the display page on the screen you want to add. It shows a six-digit pairing code."
: "Enter the six-digit code the new screen shows."
}
dismissible={!busy}
initialFocusRef={codeRef}
@@ -215,9 +237,15 @@ function AddDisplayDialog(props: {
</Field>
{naming ? (
<>
<p data-testid="device-pairing-found" style={{ margin: 0 }}>
A display is asking to show <strong>{props.moduleName(moduleId) ?? moduleId}</strong>.
</p>
{asked !== undefined ? (
<p data-testid="device-pairing-found" style={{ margin: 0 }}>
A display is asking to show <strong>{props.moduleName(asked) ?? asked}</strong>.
</p>
) : (
<p data-testid="device-pairing-found" style={{ margin: 0 }}>
A new screen is waiting to be paired.
</p>
)}
<Field label="Display name">
{(control) => (
<TextInput
@@ -233,6 +261,35 @@ function AddDisplayDialog(props: {
/>
)}
</Field>
{asked === undefined && modules !== undefined && modules.length > 0 ? (
<Field
hint="You can pair it now and choose later; until then it shows its name and waits."
label="Show on this screen"
>
{(control) => (
<Select
{...control}
data-testid="device-pairing-shows"
disabled={busy}
onChange={(event) => setShows(event.target.value)}
value={shows}
>
<option value={NO_MODULE}>Nothing yet</option>
{modules.map((module) => (
<option key={module.moduleId} value={module.moduleId}>
{module.name}
</option>
))}
</Select>
)}
</Field>
) : undefined}
{asked === undefined && modules !== undefined && modules.length === 0 ? (
<p className="ek-shell-display-card__facts" data-testid="device-pairing-no-modules">
No installed module shows content on screens yet. The display is paired now and
waits, showing its name, until one is.
</p>
) : 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 (
<div className="ek-shell-setup-link" data-testid={`display-setup-${props.setup.moduleId}`}>
{props.labelled ? (
<span className="ek-shell-setup-link__module">For {props.setup.name}</span>
) : undefined}
<div className="ek-shell-setup-link">
<span className="ek-shell-setup-link__row">
<span className="ek-shell-setup-link__url" data-testid="display-setup-url">
{props.setup.url}
{props.url}
</span>
<Button
data-testid={`display-setup-copy-${props.setup.moduleId}`}
data-testid="display-setup-copy"
onClick={(event) => {
// The clipboard of this page's own window: the shell reads no browser global here.
const clipboard = event.currentTarget.ownerDocument.defaultView?.navigator.clipboard;
@@ -266,14 +320,14 @@ function SetupLink(props: { readonly setup: HostDisplaySetup; readonly labelled:
setCopy("failed");
return;
}
clipboard.writeText(props.setup.url).then(
clipboard.writeText(props.url).then(
() => setCopy("copied"),
() => setCopy("failed"),
);
}}
size="sm"
>
Copy link
Copy setup link
</Button>
<span aria-live="polite" className="ek-shell-setup-link__status">
{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<HTMLButtonElement>) => void;
}): ReactElement {
const { setup } = props;
const none = setup.kind === "ready" && setup.displays.length === 0;
const several = setup.kind === "ready" && setup.displays.length > 1;
return (
<Panel title="Set up a new screen">
<div data-testid="display-setup" style={{ display: "grid", gap: designToken("spacing.md") }}>
{setup.kind === "loading" ? <LoadingState label="Loading setup…" /> : undefined}
{setup.kind === "failed" ? (
<p className="ek-shell-display-card__facts" data-testid="display-setup-failed">
The setup address couldn't be loaded. Reload the page to try again.
</p>
) : undefined}
{none ? (
<p className="ek-shell-display-card__facts" data-testid="display-setup-none">
No installed module shows content on screens yet. Once one is installed from Discover,
its setup address appears here.
</p>
) : undefined}
{setup.kind === "ready" && !none ? (
<>
<p className="ek-shell-setup__lead">
Open {several ? "an address" : "this address"} on the new screen, then add it with the
code it shows.
</p>
{setup.displays.map((display) => (
<SetupLink key={display.moduleId} labelled={several} setup={display} />
))}
</>
) : undefined}
{none || setup.kind === "loading" ? undefined : (
<div>
<Button data-testid="add-display" onClick={props.onAdd} variant="primary">
<ShellIcon name="addDisplay" size={18} />
Add display
</Button>
</div>
)}
{setup.kind === "ready" && !none ? (
<details className="ek-shell-setup__more" data-testid="display-setup-steps">
<summary className="ek-focusable">Step-by-step instructions</summary>
<ol className="ek-shell-setup-steps">
<li>
On the screen, open the address above in its web browser. Opening it on this device
only previews it.
</li>
<li>The screen shows a six-digit pairing code.</li>
<li>
Choose <strong>Add display</strong>, enter the code, and give the screen the name
everyone will know it by.
</li>
<li>It appears under Paired displays once it's approved.</li>
</ol>
</details>
) : undefined}
<p className="ek-shell-setup__lead">
Open this address on the new screen, then add it with the code it shows.
</p>
<SetupLink url={props.url} />
<div>
<Button data-testid="add-display" onClick={props.onAdd} variant="primary">
<ShellIcon name="addDisplay" size={18} />
Add display
</Button>
</div>
<details className="ek-shell-setup__more" data-testid="display-setup-steps">
<summary className="ek-focusable">Step-by-step instructions</summary>
<ol className="ek-shell-setup-steps">
<li>
On the screen, open the address above in its web browser. Opening it on this device
only previews it.
</li>
<li>The screen shows a six-digit pairing code.</li>
<li>
Choose <strong>Add display</strong>, enter the code, and give the screen the name
everyone will know it by. You can also choose what it shows.
</li>
<li>It appears under Paired displays once it's approved.</li>
</ol>
</details>
</div>
</Panel>
);
@@ -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<ListState>({ kind: "loading" });
const [setup, setSetup] = useState<SetupState>({ kind: "loading" });
const [modules, setModules] = useState<DisplayModules>(undefined);
const [adding, setAdding] = useState(false);
const [paired, setPaired] = useState<string | undefined>(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<HostDeviceSummary | undefined>(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 : (
<InlineNotice title="Display paired" tone="success">
<span data-testid="device-pairing-success">
{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
}.`}
</span>
</InlineNotice>
)}
@@ -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
<DisplayCard
device={device}
key={device.id}
modules={modules}
now={now}
onRevoke={() => setConfirming(device)}
/>
@@ -551,6 +596,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
<AddDisplayDialog
config={config}
moduleName={props.moduleName}
modules={modules}
onDismiss={dismissAdding}
onPaired={onPaired}
open={adding}
+1 -3
View File
@@ -24,9 +24,7 @@
* — the scroll position too.
*/
export interface ScrollLockRelease {
(options?: { readonly restoreScroll?: boolean }): void;
}
export type ScrollLockRelease = (options?: { readonly restoreScroll?: boolean }) => void;
const LOCKED_PROPERTIES = ["overflow", "overscroll-behavior"] as const;
@@ -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/<name>` 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<SetupState>({ 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<typeof setTimeout> | 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 (
<PairingScreen
config={config}
hint="Enter this code in Settings → Displays → Add display. Waiting for approval…"
moduleId={undefined}
onRetry={() => setAttempt((value) => value + 1)}
/>
);
}
if (state.status === "waiting") {
return (
<DisplayNotice
headline={state.name}
hint="Nothing is set to show here yet."
testId="display-setup-waiting"
title="This screen is paired"
/>
);
}
if (state.status === "unreachable") {
return (
<DisplayNotice
hint="Trying again…"
testId="display-setup-unreachable"
title="Connecting to the server"
/>
);
}
return <DisplayNotice testId="display-setup-loading" title="Preparing this display…" />;
}
+33 -2
View File
@@ -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 (
<div data-testid={props.testId} style={pairingStyles.root}>
<p style={pairingStyles.title}>{props.title}</p>
{props.headline === undefined ? undefined : (
<p
data-testid={`${props.testId}-headline`}
style={{ ...pairingStyles.code, fontSize: "clamp(2rem, 8vw, 5rem)", letterSpacing: 0 }}
>
{props.headline}
</p>
)}
{props.hint === undefined ? undefined : <p style={pairingStyles.hint}>{props.hint}</p>}
</div>
);
}
export function PairingScreen(props: PairingScreenProps): ReactElement {
const { config, moduleId, onRetry } = props;
const [state, setState] = useState<PairingState>({ status: "requesting" });
@@ -137,7 +167,8 @@ export function PairingScreen(props: PairingScreenProps): ReactElement {
{state.code}
</p>
<p style={pairingStyles.hint}>
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…"}
</p>
</>
) : undefined}
+6 -1
View File
@@ -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(
<>
<EkklesiaUiStyles />
{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.
<DisplaySetupApp config={config} openDisplay={(url) => window.location.replace(url)} />
) : moduleId ? (
<DisplayApp config={config} connectivity={connectivity} moduleId={moduleId} />
) : (
<div
+98 -23
View File
@@ -456,13 +456,15 @@ async function requestPlatformMutation(
*/
export async function requestDevicePairing(
config: EkklesiaHostConfig,
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). */
moduleId: string | undefined,
signal?: AbortSignal,
): Promise<{ readonly code: string; readonly pollIntervalMs: number }> {
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<void> {
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<readonly HostDisplaySetup[]> {
const payload = await requestPlatformJson(config, "/api/v1/devices/setup", signal, {
): Promise<readonly HostDisplayModule[]> {
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<HostDisplaySelf> {
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;
+2
View File
@@ -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,
@@ -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<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
async function renderSetup(server: ReturnType<typeof api>) {
const openDisplay = vi.fn();
await act(async () => {
root.render(
<DisplaySetupApp
config={{ apiBaseUrl, fetch: server.fetch }}
openDisplay={openDisplay}
recheckMs={30_000}
retryMs={5_000}
/>,
);
});
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();
});
});
+119 -58
View File
@@ -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<void> {
});
}
async function choose(testId: string, value: string): Promise<void> {
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<void> {
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");
});
});
@@ -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 });
}