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

Displays are platform-owned, but a screen could only start pairing on a
module's display page: every pairing request named a module, so an
installation with no module that uses displays could not set one up.

- /display/setup is the platform's own display page, served whenever
  the deployment has device support, whatever is installed. It pairs a
  platform display with no module. Once paired, it opens the module it
  is set to show; with none set, it shows its name and waits, rechecking
  so it stays alive and notices revocation. "setup" is now a reserved
  module id.
- Migration core:0009_platform_display_pairing lets a pairing request
  name no module. It relaxes module_id and permission_id and adds a
  both-or-neither check. It is additive and destroys nothing.
- POST /api/v1/device-pairing/requests accepts {} for a platform
  display. Approval accepts an optional moduleId, so the administrator
  can choose an installed display module and the device gets that
  module's declared permission. A module display's own request can't be
  redirected.
- GET /api/v1/device-pairing/self lets a paired device describe itself:
  its name and the module displays it may show. It renews the device's
  cookie.
- GET /api/v1/devices/setup is replaced by
  GET /api/v1/devices/display-modules: the modules a display can be set
  to show.
- Settings -> Displays always offers the /display/setup address, Copy
  setup link and Add display. Add display has an optional "Show on this
  screen" choice, and each paired display says what it shows.
- /display/<module-id> is unchanged, so released Signage 0.1.5 works as
  before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-16 16:43:02 -05:00
co-authored by Claude Opus 5.5
parent 906c015ca8
commit 300d9eab51
44 changed files with 3169 additions and 396 deletions
+123 -33
View File
@@ -1,4 +1,4 @@
import { safeParseEkklesiaDeviceId } from "@ekklesia/contracts";
import { type PermissionId, safeParseEkklesiaDeviceId } from "@ekklesia/contracts";
import { CORE_DEVICES_MANAGE } from "@ekklesia/core/authorization";
import type { DeviceStore } from "@ekklesia/core/devices";
import { DEVICE_CREDENTIAL_TTL_MS } from "@ekklesia/core/devices";
@@ -131,6 +131,17 @@ export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): vo
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
const routes = new HonoApp();
const now = options.now ?? ((): Date => new Date());
/** A running module display by id, with its declared display permission, or `undefined`. */
const findDisplay = (
moduleId: string,
): { readonly moduleId: string; readonly permissionId: PermissionId } | undefined => {
const display = options.moduleUi
.listDisplays()
.find((candidate) => candidate.moduleId === moduleId);
return display === undefined
? undefined
: { moduleId: display.moduleId, permissionId: display.displayPermission };
};
const requireManage = requirePermission(options, CORE_DEVICES_MANAGE);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
@@ -138,11 +149,16 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
/**
* An unpaired display asks to be paired.
*
* The **only** thing the caller supplies is which module's display it is sitting on, and even
* that is not taken at its word: the module is looked up in the publication registry and the
* permission is read from that module's validated display declaration. A browser therefore cannot
* ask to be granted `core.devices.manage`, another module's permission, or anything else — the
* request records the permission the host derived, and approval grants exactly that.
* Two kinds of screen ask (ADR 0065). One on the platform's own setup page (`/display/setup`)
* sends `{}`: it becomes a platform display with no module, and the approving administrator may
* choose which installed display module it shows. One on a module's display page sends
* `{ "moduleId": … }`, as released displays always have.
*
* The module, when there is one, is not taken at its word: it is looked up in the publication
* registry and the permission is read from that module's validated display declaration. A browser
* therefore cannot ask to be granted `core.devices.manage`, another module's permission, or
* anything else — any other key is ignored, the request records the permission the host derived,
* or none, and approval grants exactly that.
*/
routes.post(
`${DEVICE_PAIRING_PREFIX}/requests`,
@@ -150,25 +166,29 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
mutationBodyLimit,
async (c: Context) => {
const body = await readJsonObject(c);
const moduleId = body === undefined ? undefined : readString(body, "moduleId", 128);
if (moduleId === undefined) {
if (body === undefined) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const display = options.moduleUi
.listDisplays()
.find((candidate) => candidate.moduleId === moduleId);
if (!display) {
// Unknown, dashboard-only, disabled, or dependency-blocked all answer the same way, so
// pairing never becomes a way to enumerate what is installed.
return sanitizedFailure(c, 404, "NOT_FOUND");
let display: { readonly moduleId: string; readonly permissionId: PermissionId } | undefined;
if ("moduleId" in body) {
const moduleId = readString(body, "moduleId", 128);
if (moduleId === undefined) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const found = findDisplay(moduleId);
if (!found) {
// Unknown, dashboard-only, disabled, or dependency-blocked all answer the same way, so
// pairing never becomes a way to enumerate what is installed.
return sanitizedFailure(c, 404, "NOT_FOUND");
}
display = found;
}
try {
const at = now();
const created = await options.devices.createPairingRequest({
moduleId: display.moduleId,
...(display === undefined ? {} : { display }),
now: at,
permissionId: display.displayPermission,
});
writeDeviceCookie(
c,
@@ -265,8 +285,9 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
return c.json(
{
expiresAt: found.expiresAt.toISOString(),
moduleId: found.moduleId,
permissionId: found.permissionId,
// `null` for a platform display no module has been chosen for yet (ADR 0065).
moduleId: found.moduleId ?? null,
permissionId: found.permissionId ?? null,
requestedAt: found.createdAt.toISOString(),
},
200,
@@ -277,7 +298,15 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
},
);
/** Approval. The administrator names the device; the unpaired screen never names itself. */
/**
* Approval. The administrator names the device; the unpaired screen never names itself.
*
* `moduleId`, optional, is the installed display module the administrator chose for a platform
* display to show (ADR 0065). It must be a running display, and the permission granted is that
* module's declared display permission, never one the request names. A screen that asked from a
* module's own display page already names its module; a different `moduleId` is refused, so its
* permission can never be swapped for another module's.
*/
routes.post(
`${DEVICE_PAIRING_PREFIX}/approvals`,
requireOrigin,
@@ -287,9 +316,22 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
const body = await readJsonObject(c);
const code = body === undefined ? undefined : readString(body, "code", 16);
const deviceName = body === undefined ? undefined : readString(body, "deviceName", 128);
if (code === undefined || !/^[0-9]{6}$/u.test(code) || deviceName === undefined) {
if (
body === undefined ||
code === undefined ||
!/^[0-9]{6}$/u.test(code) ||
deviceName === undefined
) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
let display: { readonly moduleId: string; readonly permissionId: PermissionId } | undefined;
if ("moduleId" in body) {
const moduleId = readString(body, "moduleId", 128);
display = moduleId === undefined ? undefined : findDisplay(moduleId);
if (display === undefined) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
}
const principal = getAuthorizedPrincipal(c);
if (principal.kind !== "authenticated") {
// Unreachable through `requireManage`, which only ever admits a person. Stated rather than
@@ -301,6 +343,7 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
approvedBy: principal.userId,
code,
deviceName,
...(display === undefined ? {} : { display }),
now: now(),
});
if (!approved) {
@@ -309,7 +352,10 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
}
noStore(c);
return c.json({ moduleId: approved.moduleId, permissionId: approved.permissionId }, 200);
return c.json(
{ moduleId: approved.moduleId ?? null, permissionId: approved.permissionId ?? null },
200,
);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
@@ -317,31 +363,75 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
);
/**
* How a new screen starts pairing (ADR 0065): the platform display page for every running module
* that shows content on screens, for Settings → Displays to tell an administrator what to open on
* the screen. Each page is the generic display host at `/display/<module-id>` (ADR 0033), which
* shows a pairing code until the screen is approved.
* The modules a display can be set to show (ADR 0065): every running module with a display, by
* name, with the display permission showing it requires, for Settings → Displays' "Show on this
* screen" choice and to name what each paired display shows.
*
* Administrative, like the device list beside it: `core.devices.manage`, and never anonymous, so
* this does not become a way to enumerate what is installed — an unpaired screen still learns
* nothing beyond the page it was told to open. It answers names and root-relative page paths only;
* the browser resolves them against the API origin, which is where the display host lives in both
* topologies (ADR 0045). An empty list is a real answer: no installed module uses displays yet.
* it is not a way to enumerate what is installed. Setting up a screen needs none of it — that is
* the platform's own `/display/setup`, whatever is installed. An empty list is a real answer: no
* installed module uses displays yet, and a display paired now simply shows nothing until one is.
*/
routes.get(`${DEVICES_PREFIX}/setup`, requireManage, (c: Context) => {
routes.get(`${DEVICES_PREFIX}/display-modules`, requireManage, (c: Context) => {
noStore(c);
return c.json(
{
displays: options.moduleUi.listDisplays().map((display) => ({
modules: options.moduleUi.listDisplays().map((display) => ({
moduleId: display.moduleId,
name: display.name,
url: moduleDisplayPageUrl(display.moduleId),
permissionId: display.displayPermission,
})),
},
200,
);
});
/**
* A paired display asking about itself (ADR 0065): its name, and the module displays it may show
* now. The platform's setup page asks this to know whether to open a module's display, to show
* "paired as Foyer TV" while nothing is assigned, and to notice revocation. Asking also keeps an
* idle display's credential alive, exactly like a module display's descriptor (ADR 0056).
*
* Only a device is answered. A person — even an administrator who opened the page on their own
* laptop — and an unpaired screen are refused, so this reveals nothing to anyone else.
*/
routes.get(`${DEVICE_PAIRING_PREFIX}/self`, async (c: Context) => {
noStore(c);
c.header("Vary", "Cookie");
let principal: Awaited<ReturnType<typeof options.runtime.resolvePrincipal>>;
try {
principal = await options.runtime.resolvePrincipal(c.req.raw.headers);
} catch {
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
}
if (principal.kind !== "device") {
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
try {
const self = await options.devices.describeDevice(principal.deviceId);
if (!self) {
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
renewDeviceSessionCookie(c, options.secureCookies);
const held = new Set(self.permissions);
return c.json(
{
displays: options.moduleUi
.listDisplays()
.filter((display) => held.has(display.displayPermission))
.map((display) => ({
moduleId: display.moduleId,
url: moduleDisplayPageUrl(display.moduleId),
})),
name: self.name,
},
200,
);
} catch {
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
}
});
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
try {
const devices = await options.devices.listDevices();