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
+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();
|
||||
|
||||
Reference in New Issue
Block a user