Files
Ekklesia/apps/api/src/device-routes.ts
T
ekklesiaandClaude Opus 5.5 650932a170
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 39s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m22s
CI / image-build (pull_request) Successful in 1m1s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m34s
fix(devices): keep an in-use display paired, renewing its credential instead of expiring it
A device credential was accepted for a fixed 180 days from pairing, so every
working screen dropped to a pairing code on the anniversary of its pairing,
and screens paired together failed together. Measure the 180 days from when
the device was last seen instead, and have the display descriptor - which
every screen requests on start, on reconnect, and periodically - re-issue the
cookie that authenticated it with a fresh lifetime. The value is not rotated,
so a renewal lost in transit cannot strand a screen. Revoked, expired, and
unknown credentials are never renewed, a person never receives a device
cookie, and an abandoned screen still stops being an identity 180 days after
it was last seen. No migration: lastSeenAt already slides with use.

ADR 0056 supersedes the session-lifetime section of ADR 0036.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:27:27 -05:00

379 lines
14 KiB
TypeScript

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<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());
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;
}