import { createDevicePrincipal, type EkklesiaPrincipal } from "@ekklesia/contracts"; import type { DeviceStore } from "@ekklesia/core/devices"; import { DEVICE_LAST_SEEN_THROTTLE_MS } from "@ekklesia/core/devices"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { DEVICE_SESSION_COOKIE } from "./device-routes.js"; /** * Extends principal resolution with managed devices, without touching how a *person* is resolved * (ADR 0036). * * Composition rather than modification is deliberate. Core's identity runtime is the Better Auth * boundary and knows only about human sessions; teaching it about devices would put two unrelated * credential systems behind one implementation and make "which one answered?" an internal detail. * Wrapping keeps Better Auth's behaviour byte-for-byte unchanged — including that an infrastructure * failure still throws rather than degrading to anonymous — and adds exactly one fallback. * * **A human session always wins.** If Better Auth resolves a person, that is the answer and the * device cookie is never even read. This matters because a browser can hold both: an administrator * who opens a display page on their own laptop after pairing it stays themself, and no device * cookie can dilute or replace a human identity. * * Because every gate in the platform — the display page, the descriptor, module HTTP routes, * realtime — resolves principals through this one function, a device becomes a principal in exactly * one place, and every authorization decision about it is still made by the ordinary evaluator. */ export interface DevicePrincipalOptions { readonly runtime: EkklesiaIdentityRuntime; readonly devices: DeviceStore; readonly now?: () => Date; } /** RFC 6265 `cookie-string` parsing, matching how Core's own session-cookie detection reads a * header: split on `;`, take the text before the first `=` as the name, compare whole names * exactly. Never a substring test, so `x_ekklesia_device_session` is correctly not a match. */ function readCookie(headers: Headers, name: string): string | undefined { const header = headers.get("cookie"); if (header === null) { return undefined; } for (const pair of header.split(";")) { const separator = pair.indexOf("="); if (separator === -1) { continue; } if (pair.slice(0, separator).trim() === name) { const value = pair.slice(separator + 1).trim(); return value.length === 0 ? undefined : value; } } return undefined; } export function withDevicePrincipals(options: DevicePrincipalOptions): EkklesiaIdentityRuntime { const now = options.now ?? ((): Date => new Date()); return Object.freeze({ handleAuthRequest: (request: Request): Promise => options.runtime.handleAuthRequest(request), async resolvePrincipal(headers: Headers): Promise { // Unchanged, including its failure behaviour: a genuine outage still throws rather than // silently letting a device credential answer a question about a person. const human = await options.runtime.resolvePrincipal(headers); if (human.kind !== "anonymous") { return human; } const credential = readCookie(headers, DEVICE_SESSION_COOKIE); if (credential === undefined) { return human; } const at = now(); const device = await options.devices.resolveCredential(credential, at); if (!device) { // A revoked, expired, or simply wrong credential is anonymous — never an error the caller // could use to tell those three apart. return human; } // Throttled here as well as in SQL: the common case must not even issue a statement, or a // display polling realtime would turn an operational nicety into continuous write traffic. const lastSeen = device.lastSeenAt; if ( lastSeen === undefined || at.getTime() - lastSeen.getTime() >= DEVICE_LAST_SEEN_THROTTLE_MS ) { await options.devices.touchLastSeen(device.deviceId, at).catch(() => undefined); } return createDevicePrincipal(device.deviceId); }, }); }