Files
Ekklesia/apps/api/src/device-routes.ts
T
ekklesiaandClaude Opus 5.5 d9dafcb0f7
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s
Manage paired displays: themed select, reassignment, live list, no revoked clutter
Real-device review of the platform display flow found four defects in
display management:

- Shared SelectMenu in @ekklesia/ui (additive): an Ekklesia-drawn
  single choice following the WAI-ARIA select-only combobox pattern,
  replacing the native select for "Show on this screen". It has a
  themed list, a chevron inside the control, keyboard support (arrows,
  Home/End, type-ahead, Enter/Space/Tab, and Escape that closes only
  the list), pointer and touch support, and focus that stays on the
  control. It is an ek-control, so it keeps the 16px phone size.
- Newly paired displays appear without a reload. Approval doesn't
  create the display; the screen does when it claims its credential a
  few seconds later. The page now shows a "Waiting for the screen to
  connect" placeholder and refetches every second until the new display
  appears (for up to a minute), and refreshes the list every 30
  seconds.
- What a display shows can change after pairing. In the single-target
  Stage 1 model, a display's current target is its one active display
  permission, so no migration is needed.
  PUT /api/v1/devices/:id/display, with core.devices.manage, a trusted
  origin and a bounded body, revokes its other active permission rows
  (kept as history) and grants the chosen module's declared display
  permission, in one transaction on the same device. The screen notices
  within about 30 seconds: a paired device on a module page that is no
  longer set to show it goes to /display/setup instead of re-pairing,
  and /display/setup opens the new target.
- Revoked displays leave the normal list at once. Their rows stay in
  the database as history.

Each display card now shows its name, Online/Offline/Not connected yet,
"Showing: ...", last seen and paired dates, and Change and Revoke.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 18:29:15 -05:00

546 lines
21 KiB
TypeScript

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";
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import { Hono as HonoApp } from "hono";
import { getCookie, setCookie } from "hono/cookie";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import { moduleDisplayPageUrl } from "./module-display-path.js";
/**
* Host-owned device identity transport: pairing a display, and managing what has been paired
* (ADR 0036).
*
* Both prefixes are siblings of `/api/v1/modules/...` and cannot collide with any module route.
* Nothing here is reachable by module code, and no module-facing contract mentions any of it — a
* module receives a principal and its permissions, and never learns that pairing exists.
*/
export const DEVICE_PAIRING_PREFIX = "/api/v1/device-pairing";
export const DEVICES_PREFIX = "/api/v1/devices";
/**
* The device's two cookies, deliberately named nothing like Better Auth's session cookie.
*
* Distinct names are what keep the two identity systems from being confused for one another: the
* human resolver looks only for Better Auth's cookie and the device resolver looks only for these,
* so neither can ever be satisfied by the other's credential no matter what a caller sends.
*/
export const DEVICE_PAIRING_COOKIE = "ekklesia_device_pairing";
export const DEVICE_SESSION_COOKIE = "ekklesia_device_session";
/** How often an unpaired screen should ask again. Slow enough that a waiting display is not a load
* source, fast enough that someone standing at the screen sees it come alive. */
export const DEVICE_PAIRING_POLL_INTERVAL_MS = 3_000;
export interface DeviceRoutesOptions extends AuthorizationMiddlewareOptions {
readonly devices: DeviceStore;
/** The same eligible/running publication registry the display routes use. It is the only source
* of the permission a pairing request may ask for. */
readonly moduleUi: ModuleUiPublicationRegistry;
readonly trustedOrigins: ReadonlySet<string>;
/** True when the platform is reached over https, which is what `Secure` requires to be usable at
* all. Derived from the validated identity base URL, never from a request. */
readonly secureCookies: boolean;
readonly now?: () => Date;
}
function noStore(c: Context): void {
c.header("Cache-Control", "no-store");
}
function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response {
noStore(c);
return c.json({ code }, status);
}
async function readJsonObject(c: Context): Promise<Record<string, unknown> | undefined> {
try {
const body: unknown = await c.req.json();
return typeof body === "object" && body !== null && !Array.isArray(body)
? (body as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
function readString(body: Record<string, unknown>, key: string, max: number): string | undefined {
const value = body[key];
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length === 0 || trimmed.length > max ? undefined : trimmed;
}
/**
* Writes one of the device cookies.
*
* `HttpOnly` so no script — least of all a module's display bundle, which runs in this very page —
* can read the value; `SameSite=Strict` because a display only ever talks to the host that serves
* it and nothing legitimate arrives cross-site; host-only (no `Domain`) so a sibling hostname
* cannot claim it; `Path=/` because the credential is presented to the descriptor, the module API,
* and realtime alike. `Secure` follows the deployment's own scheme — asserting it on a plaintext
* development origin would make the cookie silently unusable rather than more secure.
*/
function writeDeviceCookie(
c: Context,
options: Pick<DeviceRoutesOptions, "secureCookies">,
name: string,
value: string,
maxAgeSeconds: number,
): void {
setCookie(c, name, value, {
httpOnly: true,
maxAge: maxAgeSeconds,
path: "/",
sameSite: "Strict",
secure: options.secureCookies,
});
}
/**
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
*
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
*/
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
if (credential === undefined || credential.length === 0) {
return;
}
writeDeviceCookie(
c,
{ secureCookies },
DEVICE_SESSION_COOKIE,
credential,
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
);
}
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);
/**
* An unpaired display asks to be paired.
*
* 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`,
requireOrigin,
mutationBodyLimit,
async (c: Context) => {
const body = await readJsonObject(c);
if (body === 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);
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({
...(display === undefined ? {} : { display }),
now: at,
});
writeDeviceCookie(
c,
options,
DEVICE_PAIRING_COOKIE,
created.claimSecret,
Math.max(1, Math.ceil((created.expiresAt.getTime() - at.getTime()) / 1000)),
);
noStore(c);
// The code is returned because it has to be shown on the screen. The claim secret is not:
// it goes only into the HttpOnly cookie above, so reading the code off a television tells
// an onlooker nothing they could use.
return c.json(
{
code: created.code,
expiresAt: created.expiresAt.toISOString(),
pollIntervalMs: DEVICE_PAIRING_POLL_INTERVAL_MS,
},
201,
);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
},
);
/**
* The display asks whether it has been approved yet, and collects its credential when it has.
*
* Authority here is the claim cookie and nothing else. The pairing code is not accepted — it is
* public, printed across a screen, and a flow that issued a credential to whoever presented it
* would hand the device's identity to anyone who walked past. Consumption is single-use inside
* the store, so a replayed or concurrent claim never yields a second credential.
*/
routes.post(
`${DEVICE_PAIRING_PREFIX}/claims`,
requireOrigin,
mutationBodyLimit,
async (c: Context) => {
const claimSecret = getCookie(c, DEVICE_PAIRING_COOKIE);
if (claimSecret === undefined || claimSecret.length === 0) {
return sanitizedFailure(c, 400, "DEVICE_PAIRING_UNKNOWN");
}
try {
const at = now();
const result = await options.devices.claimPairingRequest({ claimSecret, now: at });
noStore(c);
if (result.paired) {
writeDeviceCookie(
c,
options,
DEVICE_SESSION_COOKIE,
result.paired.credential,
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
);
// The pairing cookie has done its job; leaving it would keep a spent secret on the device.
writeDeviceCookie(c, options, DEVICE_PAIRING_COOKIE, "", 0);
// Deliberately no credential, device id, or permission in the body: the browser needs to
// know only that it may now load the display, and everything it needs travels in the
// Set-Cookie header where script cannot reach it.
return c.json({ status: "paired" as const }, 200);
}
return c.json({ status: result.status }, 200);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
},
);
/**
* An administrator types the code from the screen and sees what it is asking for, before deciding.
*
* The code travels in a request body rather than a path or query so it does not land in access
* logs, browser history, or a `Referer`. Reaching this at all requires `core.devices.manage`, so
* it is not an enumeration oracle for anyone who is not already trusted to approve pairings.
*/
routes.post(
`${DEVICE_PAIRING_PREFIX}/lookups`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c: Context) => {
const body = await readJsonObject(c);
const code = body === undefined ? undefined : readString(body, "code", 16);
if (code === undefined || !/^[0-9]{6}$/u.test(code)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
try {
const found = await options.devices.findPendingByCode(code, now());
if (!found) {
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
}
noStore(c);
return c.json(
{
expiresAt: found.expiresAt.toISOString(),
// `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,
);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
},
);
/**
* 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,
mutationBodyLimit,
requireManage,
async (c: Context) => {
const body = await readJsonObject(c);
const code = body === undefined ? undefined : readString(body, "code", 16);
const deviceName = body === undefined ? undefined : readString(body, "deviceName", 128);
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
// assumed, so a device could never be recorded as having approved its own pairing.
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
try {
const approved = await options.devices.approvePairingRequest({
approvedBy: principal.userId,
code,
deviceName,
...(display === undefined ? {} : { display }),
now: now(),
});
if (!approved) {
// Expired, already approved, already consumed, or never existed. One answer for all of
// them: which one it was is not information this endpoint owes anybody.
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
}
noStore(c);
return c.json(
{ moduleId: approved.moduleId ?? null, permissionId: approved.permissionId ?? null },
200,
);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
},
);
/**
* 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
* 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}/display-modules`, requireManage, (c: Context) => {
noStore(c);
return c.json(
{
modules: options.moduleUi.listDisplays().map((display) => ({
moduleId: display.moduleId,
name: display.name,
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");
}
});
/**
* Changes what a paired display shows (ADR 0065): `{ "moduleId": "<id>" }` for a running display
* module, or `{ "moduleId": null }` for nothing. The permission granted is that module's declared
* display permission, never one the request names. It is the same display — the same identity,
* name, and credential — before and after; the screen notices on its next check and opens the new
* module's page, or the platform's setup page, without being paired again.
*/
routes.put(
`${DEVICES_PREFIX}/:deviceId/display`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c: Context) => {
const parsed = safeParseEkklesiaDeviceId(c.req.param("deviceId"));
const body = await readJsonObject(c);
if (!parsed.success || body === undefined || !("moduleId" in body)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
let permissionId: PermissionId | undefined;
if (body.moduleId !== null) {
const moduleId = readString(body, "moduleId", 128);
const display = moduleId === undefined ? undefined : findDisplay(moduleId);
if (display === undefined) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
permissionId = display.permissionId;
}
const principal = getAuthorizedPrincipal(c);
if (principal.kind !== "authenticated") {
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
try {
const changed = await options.devices.setDisplayTarget({
changedBy: principal.userId,
deviceId: parsed.data,
now: now(),
permissionId,
});
if (!changed) {
// Unknown and revoked alike: neither is a display whose content can change.
return sanitizedFailure(c, 404, "NOT_FOUND");
}
noStore(c);
return c.json({ moduleId: body.moduleId === null ? null : body.moduleId }, 200);
} catch {
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
}
},
);
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
try {
const devices = await options.devices.listDevices();
noStore(c);
return c.json(
{
devices: devices.map((device) => ({
createdAt: device.createdAt.toISOString(),
id: device.id,
name: device.name,
permissions: device.permissions,
revoked: device.revokedAt !== undefined,
...(device.lastSeenAt === undefined
? {}
: { lastSeenAt: device.lastSeenAt.toISOString() }),
...(device.revokedAt === undefined
? {}
: { revokedAt: device.revokedAt.toISOString() }),
})),
},
200,
);
} catch {
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
}
});
/**
* Revocation, which is a server-side fact rather than a request that the remote browser discard
* anything. The device's own copy of its credential keeps existing and stops working.
*/
routes.post(
`${DEVICES_PREFIX}/:deviceId/revoke`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c: Context) => {
const parsed = safeParseEkklesiaDeviceId(c.req.param("deviceId"));
if (!parsed.success) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const principal = getAuthorizedPrincipal(c);
if (principal.kind !== "authenticated") {
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
try {
await options.devices.revokeDevice({
deviceId: parsed.data,
now: now(),
revokedBy: principal.userId,
});
} catch {
return sanitizedFailure(c, 404, "NOT_FOUND");
}
noStore(c);
return c.body(null, 204);
},
);
return routes;
}