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
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:
co-authored by
Claude Opus 5.5
parent
906c015ca8
commit
300d9eab51
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -55,6 +55,12 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0009_platform_display_pairing",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
+126
-47
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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…" />;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user