import { 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"; /** * 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: DeviceRoutesOptions, name: string, value: string, maxAgeSeconds: number, ): void { setCookie(c, name, value, { httpOnly: true, maxAge: maxAgeSeconds, path: "/", sameSite: "Strict", secure: options.secureCookies, }); } export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { const routes = new HonoApp(); const now = options.now ?? ((): Date => new Date()); 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. * * 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. */ routes.post( `${DEVICE_PAIRING_PREFIX}/requests`, requireOrigin, mutationBodyLimit, async (c: Context) => { const body = await readJsonObject(c); const moduleId = body === undefined ? undefined : readString(body, "moduleId", 128); if (moduleId === 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"); } try { const at = now(); const created = await options.devices.createPairingRequest({ moduleId: display.moduleId, now: at, permissionId: display.displayPermission, }); 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(), moduleId: found.moduleId, permissionId: found.permissionId, 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. */ 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 (code === undefined || !/^[0-9]{6}$/u.test(code) || deviceName === 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, 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, permissionId: approved.permissionId }, 200); } catch { return sanitizedFailure(c, 503, "DEVICE_PAIRING_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; }