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; /** 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 | undefined> { try { const body: unknown = await c.req.json(); return typeof body === "object" && body !== null && !Array.isArray(body) ? (body as Record) : undefined; } catch { return undefined; } } function readString(body: Record, 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, 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>; 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": "" }` 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; }