From 80189c8ae2cb9db5918e597f0ecd483d9243f214 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sun, 23 Aug 2026 23:41:03 -0500 Subject: [PATCH] feat: give display devices their own paired identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page. Together, the only way to light a television in a foyer was to sign a human being into it and leave that session running on a screen anyone can walk up to: a person's credentials on a wall, a person's session as the thing signage depends on, and the display going dark when they are revoked or their session simply expires. A screen is not a person. Ekklesia now recognizes three principal classes — anonymous, authenticated human, and managed device — and a device is a first-class principal with its own credential and its own explicit permissions. It has no email, no password, and no row in the identity tables, and it satisfies the `authenticated` baseline never, so every route that settles for "somebody is signed in" stays closed to it. Device permissions live in their own table because ekklesia_core_grant.user_id is a non-null foreign key into the user table and making it polymorphic would rewrite the platform's central authorization record to serve one narrow case. That is a schema decision, not a second decision point: the evaluator gained one branch, so a device is allowed or denied by the same function, with the same deny-by-default, as anyone else — with no owner path it could reach and no baseline that stands in for a grant. The six-digit code is not the credential. It is printed across a screen in a public hallway, so it is treated as public: it identifies a request to an administrator and is worth nothing alone. Collecting the credential also requires a 256-bit claim secret held only in the requesting browser's HttpOnly cookie, and approving requires the restricted core.devices.manage permission. Without that split, reading a code off a wall would hand over the screen's identity. The permission a pairing asks for is read from the module's validated display declaration, never from the request, so a browser cannot ask to be granted core.devices.manage or another module's permission. Approval grants exactly that one permission. Credentials are stored only as SHA-256; the raw value exists in the Set-Cookie that issues it and the device's own cookie jar, and is never logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so the module display code running in that same page cannot read it. Revocation is a server-side column checked on every principal resolution, and re-pairing mints a new identity so an old credential can never come back. State transitions are conditional updates, so one code cannot be approved twice or claimed into two credentials under a race. /api/v1/principal still reports a device as anonymous: it is what the Dashboard uses to decide whether a person is signed in. Displays read their viewer from the descriptor instead, which already performed the authorization — so displays are descriptor-first while the Dashboard stays principal-first, and neither infers an identity by elimination. Proven end to end against real PostgreSQL and real HTTP: an unpaired screen shows a code, an owner approves it, the device opens the display descriptor and a realtime stream, and revocation closes both while the device still holds its cookie. An already-open SSE stream is bounded by realtime's existing five-minute reconnect rather than closed instantly, and that bound is documented rather than called immediate. Co-Authored-By: Claude Opus 5 --- README.md | 37 +- apps/api/src/app.ts | 30 + apps/api/src/database.ts | 3 + apps/api/src/device-principal.ts | 94 ++ apps/api/src/device-routes.ts | 357 +++++ apps/api/src/module-display-routes.ts | 51 +- apps/api/src/server.ts | 21 +- apps/api/tests/api-http.integration.ts | 7 +- apps/api/tests/device-pairing.integration.ts | 810 ++++++++++ apps/api/tests/module-database.integration.ts | 4 +- apps/api/tests/module-display-routes.test.ts | 15 +- core/drizzle.config.ts | 1 + core/drizzle/0006_device_identity.sql | 53 + core/drizzle/meta/0006_snapshot.json | 1370 +++++++++++++++++ core/drizzle/meta/_ekklesia.json | 6 + core/drizzle/meta/_journal.json | 7 + core/package.json | 4 + core/src/authorization/evaluator.ts | 48 +- core/src/authorization/permissions.ts | 22 + core/src/authorization/ports.ts | 21 +- core/src/devices/database.ts | 16 + core/src/devices/index.ts | 3 + core/src/devices/schema.ts | 168 ++ core/src/devices/store.ts | 552 +++++++ core/tests/authorization-device.test.ts | 189 +++ core/tests/authorization-permissions.test.ts | 9 +- core/tests/authorization-store.integration.ts | 6 + core/tests/dashboard-store.integration.ts | 1 + core/tests/doctor-report.test.ts | 2 +- .../module-configuration-store.integration.ts | 15 +- .../module-lifecycle-store.integration.ts | 17 +- core/tests/module-permissions.test.ts | 4 +- ...036-managed-device-identity-and-pairing.md | 192 +++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 12 + docs/development/api.md | 37 + docs/development/authorization.md | 24 + docs/development/identity.md | 14 + docs/development/security.md | 38 + docs/development/testing.md | 24 + packages/contracts/src/identity.ts | 63 +- packages/module-ui-sdk/src/host-context.ts | 21 +- surfaces/shell/src/browser/DevicePanel.tsx | 207 +++ surfaces/shell/src/browser/HostApp.tsx | 18 +- surfaces/shell/src/display/DisplayApp.tsx | 32 +- surfaces/shell/src/display/PairingScreen.tsx | 154 ++ surfaces/shell/src/host-protocol.ts | 271 +++- surfaces/shell/tests/display-app.test.tsx | 61 +- .../shell/tests/display-host-protocol.test.ts | 52 +- surfaces/shell/tests/host-app.test.tsx | 2 + surfaces/shell/tests/modularity.test.ts | 11 +- vitest.migrations.integration.config.ts | 1 + 52 files changed, 5065 insertions(+), 113 deletions(-) create mode 100644 apps/api/src/device-principal.ts create mode 100644 apps/api/src/device-routes.ts create mode 100644 apps/api/tests/device-pairing.integration.ts create mode 100644 core/drizzle/0006_device_identity.sql create mode 100644 core/drizzle/meta/0006_snapshot.json create mode 100644 core/src/devices/database.ts create mode 100644 core/src/devices/index.ts create mode 100644 core/src/devices/schema.ts create mode 100644 core/src/devices/store.ts create mode 100644 core/tests/authorization-device.test.ts create mode 100644 docs/architecture/decisions/0036-managed-device-identity-and-pairing.md create mode 100644 surfaces/shell/src/browser/DevicePanel.tsx create mode 100644 surfaces/shell/src/display/PairingScreen.tsx diff --git a/README.md b/README.md index 9ea1a6c..9c89de0 100644 --- a/README.md +++ b/README.md @@ -651,13 +651,46 @@ adds an optional, independently declared display role: } ``` -The permission must be declared by that module and have an `authenticated` or `restricted` baseline; -public/device identity remains deferred. Primary UI and display UI are independently optional, must +The permission must be declared by that module and have an `authenticated` or `restricted` baseline. Primary UI and display UI are independently optional, must use paths distinct from every other entrypoint, and use the same `defineModuleUi({ id, version, mount })` ABI and five-member `ModuleUiHostContext` with **no mode flag**. The canonical builder reads fixed sources (`src/ui/index.tsx` and `src/display/index.tsx`) and atomically emits every declared role. +Phase 7B pass 2 gives those screens an identity of their own +([ADR 0036](docs/architecture/decisions/0036-managed-device-identity-and-pairing.md)), so a +television no longer needs a person signed into it. Ekklesia now recognizes **three** principal +classes — anonymous, authenticated human, and managed device: + +```text +screen opens /display/announcements → no identity → shows a 6-digit code +administrator types the code in the Dashboard, names the screen, approves +screen collects an HttpOnly credential → display and realtime work +administrator revokes → the screen loses access and shows a code again +``` + +**A device is not a user.** It has no email, no password, and no row in the identity tables, and it +satisfies the `authenticated` baseline *never* — so every route that settles for "somebody is signed +in" stays closed to a screen on a wall. Device authority runs through the same authorization +evaluator as everyone else's, and approval grants exactly the one display permission the host derived +from the module's own declaration. `/api/v1/principal` reports a device as anonymous on purpose: a +device cookie cannot open the Dashboard. + +**The code is not the credential.** Six digits are printed across a television in a public hallway, +so they are treated as public: they identify a request to an administrator and yield nothing on their +own. Collecting the credential also requires an HttpOnly claim cookie only the requesting browser +holds, and approving requires the restricted `core.devices.manage` permission. The credential itself +is stored only as a SHA-256 digest, never logged, never in a URL, and never readable by the module +display code running in that same page. + +Revocation is a server-side fact checked on every request, not a plea that a remote browser forget +something; re-pairing creates a **new** identity so an old credential can never come back. A revoked +screen fails its next request immediately, though an already-open realtime stream is bounded by the +existing five-minute forced reconnect rather than closed instantly. **QR scanning, per-device content +targeting, device groups, and mobile dashboards are deliberately deferred** — a future QR simply +encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device +identity at all. + Ekklesia publishes the display bundle at `GET /api/v1/module-ui///display`, serves a stable permission-gated page at `/display/`, and exposes that page's permission-gated descriptor at diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ad24cbf..b5e2c4c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import type { PermissionRegistry, } from "@ekklesia/core/authorization"; import type { DashboardStore } from "@ekklesia/core/dashboard"; +import type { DeviceStore } from "@ekklesia/core/devices"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import type { DiscoveredModule, @@ -27,6 +28,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; import { createDashboardRoutes } from "./dashboard-routes.js"; +import { createDeviceRoutes } from "./device-routes.js"; import type { DisplayHostAssets } from "./display-host-assets.js"; import { createGrantsRoutes } from "./grants-routes.js"; import { createModuleConfigurationRoutes } from "./module-configuration-routes.js"; @@ -68,6 +70,17 @@ export interface CreateApiAppOptions { * is: a caller that publishes no module UI must say so explicitly with an empty registry, never * fall through to an implicit one. See `./module-ui-routes.js`. */ readonly moduleUi: ModuleUiPublicationRegistry; + /** + * Managed display devices. Optional so every existing composition — and every test that has no + * interest in devices — builds unchanged; when it is absent no pairing route is mounted, no + * device cookie is ever read, and a device principal simply cannot come into being. + */ + readonly devices?: { + readonly store: DeviceStore; + /** Whether the deployment is reached over https, so the device cookies can assert `Secure` + * without becoming unusable on a plaintext development origin. */ + readonly secureCookies: boolean; + }; /** Surface-owned display host assets, read once by production startup. Optional only when the * eligible publication registry contains no display entrypoint; route composition rejects an * inconsistent display-bearing registry synchronously. */ @@ -369,6 +382,23 @@ export function createApiApp(options: CreateApiAppOptions): Hono { ...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}), }); + if (options.devices) { + // Host-owned device identity. Siblings of `/api/v1/modules/...`, reachable by no module, and + // mounted only when the deployment actually composed device support. + app.route( + "/", + createDeviceRoutes({ + devices: options.devices.store, + evaluator: options.authorization.evaluator, + moduleUi: options.moduleUi, + registry: options.authorization.registry, + runtime, + secureCookies: options.devices.secureCookies, + trustedOrigins: allowedOrigins, + }), + ); + } + // Layered deliberately on top of the generic inventory above rather than merged into it: the // inventory stays the host-neutral "what is mountable" contract a standalone host could consume, // and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`. diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts index f3c4c10..bfc4728 100644 --- a/apps/api/src/database.ts +++ b/apps/api/src/database.ts @@ -8,6 +8,7 @@ import { type DashboardDrizzleDatabase, } from "@ekklesia/core/dashboard"; import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database"; +import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/core/devices"; import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity"; import { createModuleConfigurationView, @@ -19,6 +20,7 @@ import { export interface ApiDatabaseHandle { readonly authorization: AuthorizationDrizzleDatabase; + readonly devices: DeviceDrizzleDatabase; readonly dashboard: DashboardDrizzleDatabase; readonly identity: IdentityDrizzleDatabase; readonly module: ModuleDatabase; @@ -50,6 +52,7 @@ export function createApiDatabase( return Object.freeze({ authorization: createAuthorizationDrizzleView(pool), close, + devices: createDeviceDrizzleView(pool), dashboard: createDashboardDrizzleView(pool), identity: createIdentityDrizzleView(pool), module: createModuleDatabaseView(pool), diff --git a/apps/api/src/device-principal.ts b/apps/api/src/device-principal.ts new file mode 100644 index 0000000..78fd10a --- /dev/null +++ b/apps/api/src/device-principal.ts @@ -0,0 +1,94 @@ +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); + }, + }); +} diff --git a/apps/api/src/device-routes.ts b/apps/api/src/device-routes.ts new file mode 100644 index 0000000..ba9a6c6 --- /dev/null +++ b/apps/api/src/device-routes.ts @@ -0,0 +1,357 @@ +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; +} diff --git a/apps/api/src/module-display-routes.ts b/apps/api/src/module-display-routes.ts index 831da95..a72dbac 100644 --- a/apps/api/src/module-display-routes.ts +++ b/apps/api/src/module-display-routes.ts @@ -39,8 +39,22 @@ export interface HostModuleDisplayDescriptor { readonly permissions: readonly string[]; } +/** + * Who the host resolved as the viewer of this display. + * + * The descriptor reports it because the display host must not ask `/api/v1/principal` — that + * endpoint answers a question about *people*, and a paired device is deliberately anonymous to it + * (ADR 0036). Reading the viewer from the response that already performed the authorization means + * the display never has to infer "no human session, so presumably a device", and the two identity + * classes never have to be told apart by elimination. + */ +export type HostDisplayViewer = + | { readonly kind: "authenticated"; readonly userId: string } + | { readonly kind: "device"; readonly deviceId: string }; + export interface HostModuleDisplayResource { readonly module: HostModuleDisplayDescriptor; + readonly viewer: HostDisplayViewer; } export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions { @@ -96,12 +110,26 @@ function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets { } } +function describeViewer(principal: EkklesiaPrincipal): HostDisplayViewer | undefined { + if (principal.kind === "authenticated") { + return { kind: "authenticated", userId: principal.userId }; + } + if (principal.kind === "device") { + return { deviceId: principal.deviceId, kind: "device" }; + } + // Unreachable behind the permission gate: the display permission is `restricted`, which no + // anonymous principal satisfies. Stated rather than assumed. + return undefined; +} + async function describeDisplay( options: ModuleDisplayRoutesOptions, principal: EkklesiaPrincipal, display: PublishableModuleDisplay, + viewer: HostDisplayViewer, ): Promise { return { + viewer, module: { api: { baseUrl: moduleApiBaseUrl(display.moduleId) }, display: { url: moduleDisplayUiArtifactUrl(display) }, @@ -126,7 +154,21 @@ function mountDisplay( // neither the permission nor the artifact; it can only hit this exact pre-composed route. const requireDisplayPermission = requirePermission(options, display.displayPermission); - app.get(moduleDisplayPageUrl(display.moduleId), requireDisplayPermission, () => { + /** + * The page itself is **not** permission-gated, and the descriptor below still is. + * + * An unattended screen has no identity until someone pairs it, so a gate here would mean a new + * television could only ever be told 403 — there would be nowhere for it to display a pairing + * code, and the only way to adopt one would be to sign a human into it, which is exactly the + * model this pass exists to remove (ADR 0036). + * + * Serving it costs nothing, because it carries nothing: these are the same inert, module-agnostic + * bytes for every display, with no module id, no data, and one absolute script URL that was + * already public. Authorization lives entirely in the descriptor — the page can render, and then + * learns it may show only a pairing code. What this does reveal is that a module with a display + * exists at that path, which any pairing flow must reveal to the device it is pairing. + */ + app.get(moduleDisplayPageUrl(display.moduleId), () => { return new Response(options.assets.html, { headers: { "Cache-Control": "no-store", @@ -141,7 +183,12 @@ function mountDisplay( c.header("Cache-Control", "no-store"); c.header("Vary", "Origin, Cookie"); try { - return c.json(await describeDisplay(options, getAuthorizedPrincipal(c), display), 200); + const principal = getAuthorizedPrincipal(c); + const viewer = describeViewer(principal); + if (!viewer) { + return displayFailure(c); + } + return c.json(await describeDisplay(options, principal, display, viewer), 200); } catch { return displayFailure(c); } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 98c83fc..76f5bb4 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -11,6 +11,7 @@ import { createAuthorizationStore, } from "@ekklesia/core/authorization"; import { createDashboardStore } from "@ekklesia/core/dashboard"; +import { createDeviceStore } from "@ekklesia/core/devices"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { createEnvironmentModuleSecretSource, @@ -32,7 +33,6 @@ import { import { type ServerType, serve } from "@hono/node-server"; import type { Hono } from "hono"; import { createApiApp } from "./app.js"; -import { loadDisplayHostAssets } from "./display-host-assets.js"; import { type ClientAddressConfigInput, type ClientAddressResolver, @@ -41,6 +41,8 @@ import { } from "./client-address.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; +import { withDevicePrincipals } from "./device-principal.js"; +import { loadDisplayHostAssets } from "./display-host-assets.js"; import { prepareStartupModuleInventory } from "./platform-permissions.js"; const connectionTimeoutMs = 10_000; @@ -419,6 +421,7 @@ export async function startApi( let services: ModuleRuntimeServices; let runtime: ReturnType; let authorizationStore: ReturnType; + let deviceStore: ReturnType; try { services = Object.freeze({ database: databaseHandle.module }); runtime = createIdentityRuntime({ @@ -427,12 +430,16 @@ export async function startApi( ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}), }); authorizationStore = createAuthorizationStore(databaseHandle.authorization); + deviceStore = createDeviceStore(databaseHandle.devices); } catch (error) { await safeCloseDatabase(databaseHandle); throw error; } const evaluator = createAuthorizationEvaluator({ + // Device authority reaches the one evaluator here. There is no second decision point: a device + // is allowed or denied by the same function, with the same deny-by-default, as a person. + devices: deviceStore, grants: authorizationStore, platformOwners: authorizationStore, }); @@ -572,7 +579,17 @@ export async function startApi( }, // A typed view over the same single pool as identity, authorization, and the module facade. dashboard: createDashboardStore(databaseHandle.dashboard), - runtime, + devices: { + // `Secure` follows the deployment's own scheme, read from the already-validated identity + // base URL rather than from any request. Production is https by configuration policy, so a + // real deployment always gets a Secure device cookie; a plaintext development origin would + // silently drop one. + secureCookies: config.identity.baseUrl.startsWith("https://"), + store: deviceStore, + }, + // Principal resolution, extended with managed devices. A human session still wins, and this + // is the only place a device credential ever becomes a principal. + runtime: withDevicePrincipals({ devices: deviceStore, runtime }), trustedOrigins: config.identity.trustedOrigins, }); } catch (error) { diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index bd5d5c9..40b2171 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -223,6 +223,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", "core:0005_module_configuration", + "core:0006_device_identity", ]); expect(aggregate.migrations[0]?.checksum).toBe( "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", @@ -2517,10 +2518,10 @@ describe.sequential("live API — installed module permission end-to-end", () => expect(moduleCheck).toMatchObject({ status: "pass" }); expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]); expect(permissionsCheck).toMatchObject({ - // Core's own five (`core.authorization.grants.manage`, `core.modules.ui.read`, + // Core's own six (`core.authorization.grants.manage`, `core.modules.ui.read`, // `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`, - // `core.modules.configuration.manage`) plus the fixture module's one. - details: ["6 permissions registered"], + // `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one. + details: ["7 permissions registered"], status: "pass", }); // Doctor and permission composition read only the manifest — the fixture's register/start diff --git a/apps/api/tests/device-pairing.integration.ts b/apps/api/tests/device-pairing.integration.ts new file mode 100644 index 0000000..379c613 --- /dev/null +++ b/apps/api/tests/device-pairing.integration.ts @@ -0,0 +1,810 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createAuthorizationDrizzleDatabase, + createInitialPlatformOwner, +} from "@ekklesia/core/authorization"; +import { + createBootstrapIdentityCreator, + createIdentityDrizzleDatabase, +} from "@ekklesia/core/identity"; +import { + applyModuleMigrations, + createPostgresMigrationDatabase, + discoverAllMigrations, +} from "@ekklesia/core/migrations"; +import { Client, type QueryResultRow } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { createApiRuntimeConfig } from "../src/config.js"; +import { type RunningApi, startApi } from "../src/server.js"; + +/** + * Device pairing end to end, against real PostgreSQL and the real HTTP transport (ADR 0036). + * + * The whole point is that none of this is mocked. A screen with no identity asks to be paired, a + * real administrator signs in through the real login transport and approves it, the host issues a + * credential in a real `Set-Cookie`, and the resulting device opens a real display descriptor and a + * real realtime stream — and is then revoked and loses all of it. Anything short of that would + * prove that rows were written, not that a television in a foyer works. + */ +const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; +const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle"); +const trackedDatabases = new Set(); +const trackedDirectories: string[] = []; +let databaseCounter = 0; + +const identityBaseUrl = "https://identity.example.test"; +const dashboardOrigin = "https://dashboard.example.test"; +const untrustedOrigin = "https://evil.example.test"; +const ownerPassword = "correct horse battery staple"; +const displayPermission = "announcements.slideshow.view"; + +class IntegrationHarnessError extends Error { + constructor(message: string) { + super(message); + this.name = "IntegrationHarnessError"; + this.stack = `${this.name}: ${this.message}`; + } +} + +function baseDatabaseUrl(): string { + const url = process.env[integrationDatabaseUrlEnvironment]; + if (typeof url !== "string" || url.trim() === "") { + throw new IntegrationHarnessError("The integration database URL is not configured."); + } + return url; +} + +function databaseUrl(name: string): string { + const url = new URL(baseDatabaseUrl()); + url.pathname = `/${name}`; + return url.toString(); +} + +async function openAdministrativeClient(): Promise { + const client = new Client({ connectionString: baseDatabaseUrl() }); + await client.connect(); + return client; +} + +async function safeQuery( + client: Client, + text: string, + values: readonly unknown[] = [], +): Promise { + try { + return (await client.query(text, [...values])).rows; + } catch { + throw new IntegrationHarnessError("An integration database statement failed."); + } +} + +async function query( + url: string, + text: string, + values: readonly unknown[] = [], +): Promise { + const client = new Client({ connectionString: url }); + await client.connect(); + try { + return await safeQuery(client, text, values); + } finally { + await client.end().catch(() => undefined); + } +} + +/** A display-capable module: one display entrypoint, one restricted permission protecting it, and + * one realtime channel gated by that same permission. */ +async function createDisplayModuleDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-")); + trackedDirectories.push(directory); + const moduleDirectory = path.join(directory, "announcements"); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: "@test/module-announcements", + type: "module", + version: "1.0.0", + }), + ), + writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify({ + compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + entrypoints: { + display: { artifact: "./dist/ui/display.js", permission: displayPermission }, + server: "./dist/server/index.js", + }, + id: "announcements", + manifestVersion: 6, + name: "Announcements fixture", + permissions: [ + { + baseline: "restricted", + description: "View the announcement slideshow.", + id: displayPermission, + }, + ], + version: "1.0.0", + }), + ), + writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + `export default { + id: "announcements", + version: "1.0.0", + register({ realtime }) { + realtime.channel({ channel: "state", permission: ${JSON.stringify(displayPermission)} }); + }, + start({ realtime }) { + realtime.publish("state", { slides: [] }); + }, + stop() {}, + };\n`, + ), + writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"), + ]); + return directory; +} + +async function createMigratedDatabase(label: string): Promise { + databaseCounter += 1; + const name = + `ekk_device_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase(); + const admin = await openAdministrativeClient(); + try { + await safeQuery(admin, `create database "${name}"`); + } finally { + await admin.end().catch(() => undefined); + } + trackedDatabases.add(name); + const url = databaseUrl(name); + const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-device-no-modules-")); + try { + const aggregate = await discoverAllMigrations({ + coreMigrationsDirectory, + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory: emptyModules, + platformVersion: "0.1.0", + }, + }); + await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); + } finally { + await rm(emptyModules, { force: true, recursive: true }); + } + return url; +} + +async function startPlatform(url: string, modulesDirectory: string): Promise { + const config = createApiRuntimeConfig( + { + databaseUrl: url, + host: "127.0.0.1", + identity: { + baseUrl: identityBaseUrl, + // The production posture: no public signup anywhere in this suite. + publicSignupEnabled: false, + secret: "integration-test-secret-0123456789abcdefgh", + trustedOrigins: [identityBaseUrl, dashboardOrigin], + }, + port: 0, + }, + { environment: "development" }, + ); + return startApi(config, { + modulesDirectoryOverride: modulesDirectory, + rateLimitOverride: { max: 1000, window: 1 }, + }); +} + +function apiOrigin(running: RunningApi): string { + const address = running.server.address(); + if (!address || typeof address !== "object") { + throw new IntegrationHarnessError("The API integration server has no bound address."); + } + return `http://127.0.0.1:${address.port}`; +} + +/** Matches a cookie by suffix, because Better Auth prefixes its session cookie with `__Secure-` + * whenever the configured base URL is https — which this harness's is. */ +function cookiePair(response: Response, name: string): string | undefined { + for (const header of response.headers.getSetCookie()) { + const pair = header.split(";")[0]?.trim() ?? ""; + const cookieName = pair.slice(0, Math.max(0, pair.indexOf("="))); + if (cookieName === name || cookieName.endsWith(name)) { + return pair; + } + } + return undefined; +} + +function setCookieFor(response: Response, name: string): string | undefined { + return response.headers.getSetCookie().find((header) => header.startsWith(`${name}=`)); +} + +async function createOwner(url: string, origin: string): Promise { + const email = `owner-${randomBytes(4).toString("hex")}@example.test`; + const authorization = createAuthorizationDrizzleDatabase(url); + const identity = createIdentityDrizzleDatabase(url, {}); + try { + await createInitialPlatformOwner( + { + authorizationDb: authorization.db, + createIdentity: createBootstrapIdentityCreator(identity.db), + identityDb: identity.db, + }, + { email, name: "Platform Owner", password: ownerPassword }, + ); + } finally { + await Promise.all([authorization.close(), identity.close()]); + } + const signIn = await fetch(`${origin}/api/auth/sign-in/email`, { + body: JSON.stringify({ email, password: ownerPassword }), + headers: { "content-type": "application/json", origin: dashboardOrigin }, + method: "POST", + }); + const cookie = cookiePair(signIn, "better-auth.session_token"); + if (signIn.status !== 200 || cookie === undefined) { + throw new IntegrationHarnessError("The platform owner could not sign in."); + } + return cookie; +} + +interface Platform { + readonly api: RunningApi; + readonly origin: string; + readonly url: string; + readonly ownerCookie: string; + readonly close: () => Promise; +} + +async function platform(label: string): Promise { + const url = await createMigratedDatabase(label); + const modulesDirectory = await createDisplayModuleDirectory(); + const api = await startPlatform(url, modulesDirectory); + const origin = apiOrigin(api); + const ownerCookie = await createOwner(url, origin); + return { + api, + close: async () => { + await api.shutdown(); + }, + origin, + ownerCookie, + url, + }; +} + +/** The device's own browser: no human session, only whatever cookies the host has set on it. */ +class DeviceBrowser { + #cookies = new Map(); + + constructor(private readonly origin: string) {} + + absorb(response: Response): void { + for (const header of response.headers.getSetCookie()) { + const pair = header.split(";")[0] ?? ""; + const separator = pair.indexOf("="); + if (separator === -1) { + continue; + } + const name = pair.slice(0, separator).trim(); + const value = pair.slice(separator + 1).trim(); + if (value.length === 0 || /max-age=0/iu.test(header)) { + this.#cookies.delete(name); + } else { + this.#cookies.set(name, value); + } + } + } + + header(): string { + return [...this.#cookies].map(([name, value]) => `${name}=${value}`).join("; "); + } + + has(name: string): boolean { + return this.#cookies.has(name); + } + + overwrite(name: string, value: string): void { + this.#cookies.set(name, value); + } + + async request(path: string, init: RequestInit = {}): Promise { + const cookie = this.header(); + const response = await fetch(`${this.origin}${path}`, { + ...init, + headers: { + origin: dashboardOrigin, + ...(cookie.length > 0 ? { cookie } : {}), + ...(init.headers as Record | undefined), + }, + }); + this.absorb(response); + return response; + } + + post(path: string, body: unknown): Promise { + return this.request(path, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", + }); + } +} + +function adminRequest( + origin: string, + cookie: string, + path: string, + body?: unknown, + overrides: RequestInit = {}, +): Promise { + return fetch(`${origin}${path}`, { + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers: { + cookie, + origin: dashboardOrigin, + ...(body === undefined ? {} : { "content-type": "application/json" }), + ...(overrides.headers as Record | undefined), + }, + method: body === undefined ? "GET" : "POST", + ...overrides, + }); +} + +/** Drives a screen from "nobody has paired me" to "I hold a device session". */ +async function pairDevice( + platform: Platform, + device: DeviceBrowser, + deviceName = "Foyer TV", +): Promise<{ readonly code: string }> { + const requested = await device.post("/api/v1/device-pairing/requests", { + moduleId: "announcements", + }); + if (requested.status !== 201) { + throw new IntegrationHarnessError("The pairing request was refused."); + } + const { code } = (await requested.json()) as { code: string }; + const approved = await adminRequest( + platform.origin, + platform.ownerCookie, + "/api/v1/device-pairing/approvals", + { code, deviceName }, + ); + if (approved.status !== 200) { + throw new IntegrationHarnessError("The pairing approval was refused."); + } + const claimed = await device.post("/api/v1/device-pairing/claims", {}); + if (((await claimed.json()) as { status: string }).status !== "paired") { + throw new IntegrationHarnessError("The device was not issued a session."); + } + return { code }; +} + +afterAll(async () => { + const admin = await openAdministrativeClient().catch(() => undefined); + if (admin) { + try { + for (const name of trackedDatabases) { + await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch( + () => undefined, + ); + } + } finally { + await admin.end().catch(() => undefined); + } + } + for (const directory of trackedDirectories.splice(0)) { + await rm(directory, { force: true, recursive: true }).catch(() => undefined); + } +}); + +describe.sequential("display device pairing, end to end", () => { + it("takes an unpaired screen all the way to a working display, and back off again", async () => { + const running = await platform("e2e"); + const device = new DeviceBrowser(running.origin); + try { + // 1. The screen loads. It has no identity, so the descriptor refuses it — but the page it + // needs in order to show a code is served. + expect((await device.request("/display/announcements")).status).toBe(200); + expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403); + + // 2. It asks to be paired and receives a code plus an HttpOnly claim cookie. + const requested = await device.post("/api/v1/device-pairing/requests", { + moduleId: "announcements", + }); + expect(requested.status).toBe(201); + const body = (await requested.json()) as Record; + expect(body.code).toMatch(/^[0-9]{6}$/u); + // The claim secret is never in the body — only in a cookie script cannot read. + expect(JSON.stringify(body)).not.toContain("claim"); + const claimCookie = setCookieFor(requested, "ekklesia_device_pairing"); + expect(claimCookie).toMatch(/HttpOnly/iu); + + // 3. Nothing has happened yet: polling says pending, and the display is still refused. + expect( + ( + (await (await device.post("/api/v1/device-pairing/claims", {})).json()) as { + status: string; + } + ).status, + ).toBe("pending"); + + // 4. The administrator looks the code up, sees what it wants, and approves it by name. + const lookup = await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/lookups", + { code: body.code }, + ); + expect(lookup.status).toBe(200); + expect(await lookup.json()).toMatchObject({ + moduleId: "announcements", + permissionId: displayPermission, + }); + expect( + ( + await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/approvals", + { code: body.code, deviceName: "Foyer TV" }, + ) + ).status, + ).toBe(200); + + // 5. The screen collects its credential — through Set-Cookie, never through JSON. + const claimed = await device.post("/api/v1/device-pairing/claims", {}); + expect(await claimed.json()).toEqual({ status: "paired" }); + const sessionCookie = setCookieFor(claimed, "ekklesia_device_session"); + expect(sessionCookie).toMatch(/HttpOnly/iu); + expect(sessionCookie).toMatch(/SameSite=Strict/iu); + expect(sessionCookie).toMatch(/Path=\//u); + expect(sessionCookie).not.toMatch(/Domain=/iu); + // The spent pairing cookie is cleared rather than left on the device. + expect(device.has("ekklesia_device_pairing")).toBe(false); + expect(device.has("ekklesia_device_session")).toBe(true); + + // 6. The display now works, and reports the device as its viewer. + const descriptor = await device.request("/api/v1/module-displays/announcements"); + expect(descriptor.status).toBe(200); + const descriptorBody = (await descriptor.json()) as { + viewer: { kind: string; deviceId?: string }; + module: { permissions: string[] }; + }; + expect(descriptorBody.viewer.kind).toBe("device"); + expect(descriptorBody.viewer.deviceId).toMatch(/^[0-9a-f-]{36}$/u); + // Exactly the one permission it was paired for. + expect(descriptorBody.module.permissions).toEqual([displayPermission]); + + // 7. Realtime, through the ordinary permission-gated channel. + const stream = await device.request("/api/v1/module-realtime/announcements/state", { + headers: { accept: "text/event-stream" }, + }); + expect(stream.status).toBe(200); + expect(stream.headers.get("content-type")).toContain("text/event-stream"); + await stream.body?.cancel(); + + // 8. The device is still nobody as far as the Dashboard is concerned. + expect(await (await device.request("/api/v1/principal")).json()).toEqual({ + kind: "anonymous", + }); + expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403); + expect((await device.request("/api/v1/devices")).status).toBe(403); + + // 9. The administrator revokes it. + const devices = (await ( + await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices") + ).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> }; + expect(devices.devices).toHaveLength(1); + expect(devices.devices[0]?.name).toBe("Foyer TV"); + expect(devices.devices[0]?.permissions).toEqual([displayPermission]); + const revoked = await adminRequest( + running.origin, + running.ownerCookie, + `/api/v1/devices/${devices.devices[0]?.id}/revoke`, + {}, + ); + expect(revoked.status).toBe(204); + + // 10. Everything it could do, it can no longer do — while still holding its cookie. + expect(device.has("ekklesia_device_session")).toBe(true); + expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403); + expect( + ( + await device.request("/api/v1/module-realtime/announcements/state", { + headers: { accept: "text/event-stream" }, + }) + ).status, + ).toBe(403); + // And the screen can start over: the page it needs for a new code is still served. + expect((await device.request("/display/announcements")).status).toBe(200); + } finally { + await running.close(); + } + }); + + it("stores no usable credential, and puts none in any response body", async () => { + const running = await platform("secrets"); + const device = new DeviceBrowser(running.origin); + try { + await pairDevice(running, device); + const credential = device.header().replace(/^.*ekklesia_device_session=/u, ""); + expect(credential.length).toBeGreaterThan(20); + + const stored = await query<{ credential_hash: string; name: string }>( + running.url, + `select credential_hash, name from ekklesia_core_device`, + ); + expect(stored).toHaveLength(1); + // The raw credential is nowhere in the database — only an irreversible digest of it. + expect(stored[0]?.credential_hash).not.toContain(credential); + expect(stored[0]?.credential_hash).toMatch(/^[0-9a-f]{64}$/u); + + const everything = JSON.stringify( + await query(running.url, `select * from ekklesia_core_device_pairing_request`), + ); + expect(everything).not.toContain(credential); + + // The descriptor a module's UI can read carries no credential either. + const descriptor = await device.request("/api/v1/module-displays/announcements"); + expect(await descriptor.text()).not.toContain(credential); + } finally { + await running.close(); + } + }); + + describe("pairing cannot be talked into anything", () => { + it("derives the permission from the display, never from the request", async () => { + const running = await platform("derive"); + const device = new DeviceBrowser(running.origin); + try { + // The browser tries to name its own permission and its own module. + const requested = await device.post("/api/v1/device-pairing/requests", { + moduleId: "announcements", + permission: "core.devices.manage", + permissionId: "core.devices.manage", + }); + expect(requested.status).toBe(201); + const { code } = (await requested.json()) as { code: string }; + + const lookup = await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/lookups", + { code }, + ); + // The host recorded the display's own declared permission and ignored the rest. + expect(await lookup.json()).toMatchObject({ permissionId: displayPermission }); + + const stored = await query<{ permission_id: string }>( + running.url, + `select permission_id from ekklesia_core_device_pairing_request`, + ); + expect(stored.map((row) => row.permission_id)).toEqual([displayPermission]); + } finally { + await running.close(); + } + }); + + it("refuses to start pairing for a module that has no display", async () => { + const running = await platform("nodisplay"); + const device = new DeviceBrowser(running.origin); + try { + for (const moduleId of ["calendar", "core", "../announcements", ""]) { + const response = await device.post("/api/v1/device-pairing/requests", { moduleId }); + expect(response.status, moduleId).toBeGreaterThanOrEqual(400); + } + expect( + await query(running.url, `select id from ekklesia_core_device_pairing_request`), + ).toHaveLength(0); + } finally { + await running.close(); + } + }); + + it("issues nothing to someone who knows only the code", async () => { + const running = await platform("codeonly"); + const device = new DeviceBrowser(running.origin); + const onlooker = new DeviceBrowser(running.origin); + try { + const requested = await device.post("/api/v1/device-pairing/requests", { + moduleId: "announcements", + }); + const { code } = (await requested.json()) as { code: string }; + await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/approvals", + { + code, + deviceName: "Foyer TV", + }, + ); + + // Someone who read the code off the television — and even guessed the request exists — + // holds no claim cookie, so there is nothing for them to collect. + const stolen = await onlooker.post("/api/v1/device-pairing/claims", {}); + expect(stolen.status).toBe(400); + expect(onlooker.has("ekklesia_device_session")).toBe(false); + + // The real screen still gets its credential. + const claimed = await device.post("/api/v1/device-pairing/claims", {}); + expect(await claimed.json()).toEqual({ status: "paired" }); + } finally { + await running.close(); + } + }); + }); + + describe("approval is an administrator's act", () => { + it("refuses an anonymous or unauthorized caller, and enforces a trusted origin", async () => { + const running = await platform("approve"); + const device = new DeviceBrowser(running.origin); + try { + const requested = await device.post("/api/v1/device-pairing/requests", { + moduleId: "announcements", + }); + const { code } = (await requested.json()) as { code: string }; + + // Anonymous. + const anonymous = await fetch(`${running.origin}/api/v1/device-pairing/approvals`, { + body: JSON.stringify({ code, deviceName: "Intruder TV" }), + headers: { "content-type": "application/json", origin: dashboardOrigin }, + method: "POST", + }); + expect(anonymous.status).toBe(403); + + // The paired-device cookie is not an administrator either — a device can never approve + // another device, or itself. + const selfApproval = await device.post("/api/v1/device-pairing/approvals", { + code, + deviceName: "Self TV", + }); + expect(selfApproval.status).toBe(403); + + // A real administrator from an untrusted origin: CSRF policy applies before anything else. + const crossOrigin = await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/approvals", + { code, deviceName: "Foyer TV" }, + { headers: { origin: untrustedOrigin } }, + ); + expect(crossOrigin.status).toBe(403); + + // Nothing was approved by any of that. + expect( + ( + (await (await device.post("/api/v1/device-pairing/claims", {})).json()) as { + status: string; + } + ).status, + ).toBe("pending"); + } finally { + await running.close(); + } + }); + + it("cannot approve the same code twice, and cannot mint two credentials from one", async () => { + const running = await platform("single"); + const device = new DeviceBrowser(running.origin); + try { + const requested = await device.post("/api/v1/device-pairing/requests", { + moduleId: "announcements", + }); + const { code } = (await requested.json()) as { code: string }; + expect( + ( + await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/approvals", + { code, deviceName: "Foyer TV" }, + ) + ).status, + ).toBe(200); + // A second approval of the same code finds nothing pending. + expect( + ( + await adminRequest( + running.origin, + running.ownerCookie, + "/api/v1/device-pairing/approvals", + { code, deviceName: "Second TV" }, + ) + ).status, + ).toBe(404); + + // Two claims race for one approved request; exactly one credential may exist. + const [first, second] = await Promise.all([ + device.post("/api/v1/device-pairing/claims", {}), + device.post("/api/v1/device-pairing/claims", {}), + ]); + const statuses = [ + ((await first.json()) as { status: string }).status, + ((await second.json()) as { status: string }).status, + ]; + expect(statuses.filter((status) => status === "paired")).toHaveLength(1); + expect(await query(running.url, `select id from ekklesia_core_device`)).toHaveLength(1); + } finally { + await running.close(); + } + }); + }); + + it("re-pairs a revoked screen into a fresh identity, leaving the old credential dead", async () => { + const running = await platform("repair"); + const device = new DeviceBrowser(running.origin); + try { + await pairDevice(running, device, "Foyer TV"); + const firstCredential = device.header().replace(/^.*ekklesia_device_session=/u, ""); + const listed = (await ( + await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices") + ).json()) as { devices: Array<{ id: string }> }; + await adminRequest( + running.origin, + running.ownerCookie, + `/api/v1/devices/${listed.devices[0]?.id}/revoke`, + {}, + ); + + await pairDevice(running, device, "Foyer TV (replaced)"); + const secondCredential = device.header().replace(/^.*ekklesia_device_session=/u, ""); + expect(secondCredential).not.toBe(firstCredential); + expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200); + + // Re-pairing creates a new identity rather than reviving the revoked one, so the old + // credential can never come back — presenting it again is simply anonymous. + const revokedBrowser = new DeviceBrowser(running.origin); + revokedBrowser.overwrite("ekklesia_device_session", firstCredential); + expect((await revokedBrowser.request("/api/v1/module-displays/announcements")).status).toBe( + 403, + ); + + const devices = (await ( + await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices") + ).json()) as { devices: Array<{ revoked: boolean }> }; + expect(devices.devices).toHaveLength(2); + expect(devices.devices.filter((entry) => entry.revoked)).toHaveLength(1); + } finally { + await running.close(); + } + }); + + it("keeps a human administrator's own session unaffected by a device cookie", async () => { + const running = await platform("mixed"); + const device = new DeviceBrowser(running.origin); + try { + await pairDevice(running, device); + const deviceCredential = device.header(); + + // A browser presenting both: the person still wins, and stays a person. + const both = await fetch(`${running.origin}/api/v1/principal`, { + headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` }, + }); + expect(((await both.json()) as { kind: string }).kind).toBe("authenticated"); + + // And that administrator can still open the display for testing, as a human. + const descriptor = await fetch(`${running.origin}/api/v1/module-displays/announcements`, { + headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` }, + }); + expect(descriptor.status).toBe(200); + expect(((await descriptor.json()) as { viewer: { kind: string } }).viewer.kind).toBe( + "authenticated", + ); + } finally { + await running.close(); + } + }); +}); diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts index a8e93f9..a31aec5 100644 --- a/apps/api/tests/module-database.integration.ts +++ b/apps/api/tests/module-database.integration.ts @@ -12,7 +12,7 @@ import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it } from "vitest"; import { createApiRuntimeConfig } from "../src/config.js"; import { createApiDatabase } from "../src/database.js"; -import { startApi, type RunningApi } from "../src/server.js"; +import { type RunningApi, startApi } from "../src/server.js"; /** * Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module @@ -223,6 +223,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", "core:0005_module_configuration", + "core:0006_device_identity", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -251,6 +252,7 @@ async function applyMigrationsWithModule(url: string): Promise { "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", "core:0005_module_configuration", + "core:0006_device_identity", "db-facade:0000_initial", ]); expect(aggregate.migrations[0]?.checksum).toBe( diff --git a/apps/api/tests/module-display-routes.test.ts b/apps/api/tests/module-display-routes.test.ts index 0a9a288..b5fc747 100644 --- a/apps/api/tests/module-display-routes.test.ts +++ b/apps/api/tests/module-display-routes.test.ts @@ -220,7 +220,7 @@ function signedIn(): EkklesiaPrincipal { } describe("module display routes — authorization and descriptor", () => { - it("does not let an anonymous viewer open either the page or descriptor", async () => { + it("gives an unpaired viewer the inert shell but never the descriptor", async () => { const { app } = await install({ display: true }); const [page, descriptor] = await Promise.all([ @@ -228,18 +228,22 @@ describe("module display routes — authorization and descriptor", () => { app.request(moduleDisplayDescriptorUrl("announcements")), ]); - expect(page.status).toBe(403); + // The page is deliberately open: a television nobody has paired has no identity to present, and + // a 403 here would leave it nowhere to show a pairing code (ADR 0036). It carries nothing — + // the same module-agnostic bytes every display gets, with no module id and no data. + expect(page.status).toBe(200); + expect(await page.text()).toBe(displayHostAssets.html); + + // Authorization is entirely in the descriptor, and it has not moved. expect(descriptor.status).toBe(403); - expect(page.headers.get("cache-control")).toBe("no-store"); expect(descriptor.headers.get("cache-control")).toBe("no-store"); - expect(await page.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); }); it("requires the declared display permission, not merely an authenticated principal", async () => { const { app } = await install({ display: true, principal: signedIn() }); - expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(403); + // Being signed in is not being permitted, and the descriptor is where that is decided. expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403); }); @@ -260,6 +264,7 @@ describe("module display routes — authorization and descriptor", () => { expect(page.headers.get("x-content-type-options")).toBe("nosniff"); expect(await page.text()).toBe(displayHostAssets.html); expect(await descriptor.json()).toEqual({ + viewer: { kind: "authenticated", userId: viewerId }, module: { api: { baseUrl: moduleApiBaseUrl("announcements") }, display: { diff --git a/core/drizzle.config.ts b/core/drizzle.config.ts index 0db19ae..dcc4580 100644 --- a/core/drizzle.config.ts +++ b/core/drizzle.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ "./src/dashboard/schema.ts", "./src/modules/lifecycle-schema.ts", "./src/modules/configuration-schema.ts", + "./src/devices/schema.ts", ], out: "./drizzle", migrations: { diff --git a/core/drizzle/0006_device_identity.sql b/core/drizzle/0006_device_identity.sql new file mode 100644 index 0000000..31c709e --- /dev/null +++ b/core/drizzle/0006_device_identity.sql @@ -0,0 +1,53 @@ +CREATE TABLE "ekklesia_core_device" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "credential_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_by_user_id" text, + "last_seen_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "revoked_by_user_id" text, + CONSTRAINT "ekklesia_core_device_name_check" CHECK (length("ekklesia_core_device"."name") between 1 and 128), + CONSTRAINT "ekklesia_core_device_credential_hash_check" CHECK (length("ekklesia_core_device"."credential_hash") = 64) +); +--> statement-breakpoint +CREATE TABLE "ekklesia_core_device_pairing_request" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "code" text NOT NULL, + "claim_secret_hash" text NOT NULL, + "module_id" text NOT NULL, + "permission_id" text NOT NULL, + "state" text DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "device_name" text, + "approved_at" timestamp with time zone, + "approved_by_user_id" text, + "consumed_at" timestamp with time zone, + "device_id" uuid, + CONSTRAINT "ekklesia_core_device_pairing_code_check" CHECK ("ekklesia_core_device_pairing_request"."code" ~ '^[0-9]{6}$'), + CONSTRAINT "ekklesia_core_device_pairing_claim_hash_check" CHECK (length("ekklesia_core_device_pairing_request"."claim_secret_hash") = 64), + CONSTRAINT "ekklesia_core_device_pairing_state_check" CHECK ("ekklesia_core_device_pairing_request"."state" in ('pending', 'approved', 'consumed')) +); +--> statement-breakpoint +CREATE TABLE "ekklesia_core_device_permission" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "device_id" uuid NOT NULL, + "permission_id" text NOT NULL, + "granted_at" timestamp with time zone DEFAULT now() NOT NULL, + "granted_by_user_id" text, + "revoked_at" timestamp with time zone, + CONSTRAINT "ekklesia_core_device_permission_id_check" CHECK (length("ekklesia_core_device_permission"."permission_id") between 1 and 128) +); +--> statement-breakpoint +ALTER TABLE "ekklesia_core_device" ADD CONSTRAINT "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ekklesia_core_device" ADD CONSTRAINT "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("revoked_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("approved_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."ekklesia_core_device"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ekklesia_core_device_permission" ADD CONSTRAINT "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."ekklesia_core_device"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ekklesia_core_device_permission" ADD CONSTRAINT "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("granted_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "ekklesia_core_device_credential_hash_idx" ON "ekklesia_core_device" USING btree ("credential_hash");--> statement-breakpoint +CREATE INDEX "ekklesia_core_device_revoked_idx" ON "ekklesia_core_device" USING btree ("revoked_at");--> statement-breakpoint +CREATE UNIQUE INDEX "ekklesia_core_device_pairing_code_idx" ON "ekklesia_core_device_pairing_request" USING btree ("code") WHERE "ekklesia_core_device_pairing_request"."state" <> 'consumed';--> statement-breakpoint +CREATE INDEX "ekklesia_core_device_pairing_claim_idx" ON "ekklesia_core_device_pairing_request" USING btree ("claim_secret_hash");--> statement-breakpoint +CREATE INDEX "ekklesia_core_device_permission_lookup_idx" ON "ekklesia_core_device_permission" USING btree ("device_id","permission_id"); \ No newline at end of file diff --git a/core/drizzle/meta/0006_snapshot.json b/core/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..25089a7 --- /dev/null +++ b/core/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1370 @@ +{ + "id": "2b1ddb4b-3185-417b-9920-100e35fedb36", + "prevId": "d41e490c-dbe1-4d73-9e11-6e79baa91908", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ekklesia_core_account": { + "name": "ekklesia_core_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_account_user_id_idx": { + "name": "ekklesia_core_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_account_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_account", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_session": { + "name": "ekklesia_core_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_session_user_id_idx": { + "name": "ekklesia_core_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_session_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_session", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_session_token_unique": { + "name": "ekklesia_core_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_user": { + "name": "ekklesia_core_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_user_email_unique": { + "name": "ekklesia_core_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_verification": { + "name": "ekklesia_core_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_verification_identifier_idx": { + "name": "ekklesia_core_verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_authorization_audit": { + "name": "ekklesia_core_authorization_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_authorization_audit_subject_idx": { + "name": "ekklesia_core_authorization_audit_subject_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_authorization_audit_action_check": { + "name": "ekklesia_core_authorization_audit_action_check", + "value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')" + }, + "ekklesia_core_authorization_audit_actor_type_check": { + "name": "ekklesia_core_authorization_audit_actor_type_check", + "value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')" + }, + "ekklesia_core_authorization_audit_scope_check": { + "name": "ekklesia_core_authorization_audit_scope_check", + "value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_grant": { + "name": "ekklesia_core_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_grant_user_permission_idx": { + "name": "ekklesia_core_grant_user_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_grant", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_grant_source_kind_check": { + "name": "ekklesia_core_grant_source_kind_check", + "value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')" + }, + "ekklesia_core_grant_created_actor_type_check": { + "name": "ekklesia_core_grant_created_actor_type_check", + "value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_grant_revoked_actor_type_check": { + "name": "ekklesia_core_grant_revoked_actor_type_check", + "value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner": { + "name": "ekklesia_core_platform_owner", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_by_actor_type": { + "name": "granted_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by_actor_id": { + "name": "granted_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_platform_owner", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_granted_actor_type_check": { + "name": "ekklesia_core_platform_owner_granted_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_platform_owner_revoked_actor_type_check": { + "name": "ekklesia_core_platform_owner_revoked_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner_bootstrap": { + "name": "ekklesia_core_platform_owner_bootstrap", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumed_for_user_id": { + "name": "consumed_for_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_bootstrap_singleton_check": { + "name": "ekklesia_core_platform_owner_bootstrap_singleton_check", + "value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_dashboard_group": { + "name": "ekklesia_core_dashboard_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_dashboard_group_order_idx": { + "name": "ekklesia_core_dashboard_group_order_idx", + "columns": [ + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_dashboard_group_id_check": { + "name": "ekklesia_core_dashboard_group_id_check", + "value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0" + }, + "ekklesia_core_dashboard_group_label_check": { + "name": "ekklesia_core_dashboard_group_label_check", + "value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_dashboard_placement": { + "name": "ekklesia_core_dashboard_placement", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_dashboard_placement_group_idx": { + "name": "ekklesia_core_dashboard_placement_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": { + "name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk", + "tableFrom": "ekklesia_core_dashboard_placement", + "tableTo": "ekklesia_core_dashboard_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_dashboard_placement_module_check": { + "name": "ekklesia_core_dashboard_placement_module_check", + "value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_module_state": { + "name": "ekklesia_core_module_state", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_module_state_module_check": { + "name": "ekklesia_core_module_state_module_check", + "value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_module_configuration": { + "name": "ekklesia_core_module_configuration", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_module_configuration_module_idx": { + "name": "ekklesia_core_module_configuration_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ekklesia_core_module_configuration_module_id_key_pk": { + "name": "ekklesia_core_module_configuration_module_id_key_pk", + "columns": ["module_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_module_configuration_module_check": { + "name": "ekklesia_core_module_configuration_module_check", + "value": "length(\"ekklesia_core_module_configuration\".\"module_id\") > 0" + }, + "ekklesia_core_module_configuration_key_check": { + "name": "ekklesia_core_module_configuration_key_check", + "value": "length(\"ekklesia_core_module_configuration\".\"key\") > 0" + }, + "ekklesia_core_module_configuration_value_check": { + "name": "ekklesia_core_module_configuration_value_check", + "value": "length(\"ekklesia_core_module_configuration\".\"value\") <= 2048" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_device": { + "name": "ekklesia_core_device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_device_credential_hash_idx": { + "name": "ekklesia_core_device_credential_hash_idx", + "columns": [ + { + "expression": "credential_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ekklesia_core_device_revoked_idx": { + "name": "ekklesia_core_device_revoked_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["revoked_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_device_name_check": { + "name": "ekklesia_core_device_name_check", + "value": "length(\"ekklesia_core_device\".\"name\") between 1 and 128" + }, + "ekklesia_core_device_credential_hash_check": { + "name": "ekklesia_core_device_credential_hash_check", + "value": "length(\"ekklesia_core_device\".\"credential_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_device_pairing_request": { + "name": "ekklesia_core_device_pairing_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_device_pairing_code_idx": { + "name": "ekklesia_core_device_pairing_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"ekklesia_core_device_pairing_request\".\"state\" <> 'consumed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "ekklesia_core_device_pairing_claim_idx": { + "name": "ekklesia_core_device_pairing_claim_idx", + "columns": [ + { + "expression": "claim_secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device_pairing_request", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["approved_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk": { + "name": "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk", + "tableFrom": "ekklesia_core_device_pairing_request", + "tableTo": "ekklesia_core_device", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_device_pairing_code_check": { + "name": "ekklesia_core_device_pairing_code_check", + "value": "\"ekklesia_core_device_pairing_request\".\"code\" ~ '^[0-9]{6}$'" + }, + "ekklesia_core_device_pairing_claim_hash_check": { + "name": "ekklesia_core_device_pairing_claim_hash_check", + "value": "length(\"ekklesia_core_device_pairing_request\".\"claim_secret_hash\") = 64" + }, + "ekklesia_core_device_pairing_state_check": { + "name": "ekklesia_core_device_pairing_state_check", + "value": "\"ekklesia_core_device_pairing_request\".\"state\" in ('pending', 'approved', 'consumed')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_device_permission": { + "name": "ekklesia_core_device_permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_device_permission_lookup_idx": { + "name": "ekklesia_core_device_permission_lookup_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk": { + "name": "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk", + "tableFrom": "ekklesia_core_device_permission", + "tableTo": "ekklesia_core_device", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device_permission", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["granted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_device_permission_id_check": { + "name": "ekklesia_core_device_permission_id_check", + "value": "length(\"ekklesia_core_device_permission\".\"permission_id\") between 1 and 128" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/core/drizzle/meta/_ekklesia.json b/core/drizzle/meta/_ekklesia.json index 8eb093e..7c4486b 100644 --- a/core/drizzle/meta/_ekklesia.json +++ b/core/drizzle/meta/_ekklesia.json @@ -37,6 +37,12 @@ "destructive": { "mode": "none" } + }, + { + "tag": "0006_device_identity", + "destructive": { + "mode": "none" + } } ] } diff --git a/core/drizzle/meta/_journal.json b/core/drizzle/meta/_journal.json index 5a273da..55e43d5 100644 --- a/core/drizzle/meta/_journal.json +++ b/core/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1787451496425, "tag": "0005_module_configuration", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1787543773057, + "tag": "0006_device_identity", + "breakpoints": true } ] } diff --git a/core/package.json b/core/package.json index 805ed4c..4f7a9ea 100644 --- a/core/package.json +++ b/core/package.json @@ -42,6 +42,10 @@ "./database": { "types": "./dist/database/index.d.ts", "import": "./dist/database/index.js" + }, + "./devices": { + "types": "./dist/devices/index.d.ts", + "import": "./dist/devices/index.js" } }, "scripts": { diff --git a/core/src/authorization/evaluator.ts b/core/src/authorization/evaluator.ts index 8b8f43a..f7402fa 100644 --- a/core/src/authorization/evaluator.ts +++ b/core/src/authorization/evaluator.ts @@ -5,11 +5,16 @@ import { type AuthorizationScope, type EkklesiaPrincipal, isAuthenticatedPrincipal, + isDevicePrincipal, permissionBaselineSchema, safeParseAuthorizationScope, safeParsePermissionId, } from "@ekklesia/contracts"; -import type { AuthorizationGrantPort, AuthorizationPlatformOwnerPort } from "./ports.js"; +import type { + AuthorizationDevicePort, + AuthorizationGrantPort, + AuthorizationPlatformOwnerPort, +} from "./ports.js"; export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED"; @@ -54,6 +59,10 @@ export interface AuthorizationEvaluator { export interface CreateAuthorizationEvaluatorOptions { readonly grants: AuthorizationGrantPort; readonly platformOwners: AuthorizationPlatformOwnerPort; + /** Device authority. Optional so every existing caller composes unchanged; when it is absent a + * device principal simply has no permissions and is denied, which is the correct answer for a + * deployment that has no device support wired at all. */ + readonly devices?: AuthorizationDevicePort; /** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the * real wall clock; production composition must leave this unset. */ readonly now?: () => Date; @@ -100,17 +109,14 @@ export function createAuthorizationEvaluator( } if (parsedBaseline.data === "authenticated") { + // A device deliberately does not satisfy this. The baseline means "a person is signed in", + // and every route that settles for it — the module UI inventory, anything a human is + // expected to be looking at — would otherwise open to an unattended screen (ADR 0036). return isAuthenticatedPrincipal(request.principal) ? AUTHORIZATION_ALLOWED : AUTHORIZATION_DENIED; } - // parsedBaseline.data === "restricted": requires an authenticated principal AND - // (an active matching grant OR platform-owner authority). Baseline alone never satisfies it. - if (!isAuthenticatedPrincipal(request.principal)) { - return AUTHORIZATION_DENIED; - } - let scope: AuthorizationScope | undefined; if (request.scope !== undefined) { const parsedScope = safeParseAuthorizationScope(request.scope); @@ -120,6 +126,34 @@ export function createAuthorizationEvaluator( scope = parsedScope.data; } + // A device satisfies `restricted` only by holding that exact permission, and by no other + // route: there is no owner check here, no baseline that stands in for a grant, and no branch + // that returns `allowed` before this lookup. "Paired" is not authority — a device that was + // paired for one display has nothing else. + if (isDevicePrincipal(request.principal)) { + if (!options.devices || scope !== undefined) { + // A scoped check has no device meaning yet; denying is the honest answer rather than + // silently treating it as unscoped. + return AUTHORIZATION_DENIED; + } + try { + return (await options.devices.hasActivePermission( + request.principal.deviceId, + parsedPermission.data, + )) + ? AUTHORIZATION_ALLOWED + : AUTHORIZATION_DENIED; + } catch { + throw new AuthorizationEvaluationError(); + } + } + + // parsedBaseline.data === "restricted": requires an authenticated principal AND + // (an active matching grant OR platform-owner authority). Baseline alone never satisfies it. + if (!isAuthenticatedPrincipal(request.principal)) { + return AUTHORIZATION_DENIED; + } + const userId = request.principal.userId; let isOwner: boolean; let hasGrant: boolean; diff --git a/core/src/authorization/permissions.ts b/core/src/authorization/permissions.ts index 3cb081b..c365db2 100644 --- a/core/src/authorization/permissions.ts +++ b/core/src/authorization/permissions.ts @@ -152,6 +152,23 @@ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId "core.modules.configuration.manage", ); +/** + * Restricted, and deliberately a **platform** permission rather than a module's. + * + * A device is an identity the platform issues, so deciding that a screen in the foyer may exist — + * approving its pairing, seeing what has been paired, and taking it away again — is authority over + * Ekklesia itself, not over the module whose display happens to be shown. Letting a module declare + * or gate it would mean whoever administers a slideshow could mint credentials that outlive it. + * + * It gates approval, listing, and revocation together. Splitting "see the paired devices" from + * "revoke one" would be ceremony: the same person is trusted with both, and a reader who cannot act + * on what they see is not a role anyone has asked for (ADR 0027's reasoning, applied again). + * + * Holding it confers **no** authority inside any module and no ability to read a device credential + * — none is stored in a recoverable form, so there is no code path that could serve one (ADR 0036). + */ +export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage"); + /** * Core's own declarations, exported in deeply immutable form so a trusted composer (the module * integration layer, `core/src/modules/permissions.ts`) can build one combined registry from @@ -185,6 +202,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob description: "Read and change an installed module's own configuration values.", id: CORE_MODULES_CONFIGURATION_MANAGE, }), + Object.freeze({ + baseline: "restricted" as const, + description: "Approve display device pairing, list paired devices, and revoke a device.", + id: CORE_DEVICES_MANAGE, + }), ]); /** diff --git a/core/src/authorization/ports.ts b/core/src/authorization/ports.ts index 37c06f4..06b6339 100644 --- a/core/src/authorization/ports.ts +++ b/core/src/authorization/ports.ts @@ -1,4 +1,9 @@ -import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts"; +import type { + AuthorizationScope, + EkklesiaDeviceId, + EkklesiaUserId, + PermissionId, +} from "@ekklesia/contracts"; export interface ActiveGrantQuery { readonly userId: EkklesiaUserId; @@ -26,3 +31,17 @@ export interface AuthorizationGrantPort { export interface AuthorizationPlatformOwnerPort { isPlatformOwner(userId: EkklesiaUserId): Promise; } + +/** + * The evaluator's only read dependency for **device** authority. + * + * Separate from `AuthorizationGrantPort` because the two answer questions about different kinds of + * subject, not because devices are a special case in the decision: `authorize` consults exactly one + * of them per call, by principal kind, and neither can be reached without going through it. + * + * There is deliberately no device counterpart to `AuthorizationPlatformOwnerPort`. A device is + * never a platform owner, so there is no port that could accidentally say it was. + */ +export interface AuthorizationDevicePort { + hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise; +} diff --git a/core/src/devices/database.ts b/core/src/devices/database.ts new file mode 100644 index 0000000..690fce6 --- /dev/null +++ b/core/src/devices/database.ts @@ -0,0 +1,16 @@ +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { Pool } from "pg"; +import { deviceSchema } from "./schema.js"; + +export type DeviceDrizzleDatabase = NodePgDatabase; + +/** + * A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`. + * + * There is deliberately no pool-constructing counterpart: device identity is only ever read and + * written by the running API, which already owns one validated pool. A second constructor would be + * a second place for transport policy to drift. + */ +export function createDeviceDrizzleView(pool: Pool): DeviceDrizzleDatabase { + return drizzle(pool, { schema: deviceSchema }); +} diff --git a/core/src/devices/index.ts b/core/src/devices/index.ts new file mode 100644 index 0000000..710fc7c --- /dev/null +++ b/core/src/devices/index.ts @@ -0,0 +1,3 @@ +export * from "./database.js"; +export * from "./schema.js"; +export * from "./store.js"; diff --git a/core/src/devices/schema.ts b/core/src/devices/schema.ts new file mode 100644 index 0000000..a470d28 --- /dev/null +++ b/core/src/devices/schema.ts @@ -0,0 +1,168 @@ +import { relations, sql } from "drizzle-orm"; +import { check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { ekklesiaCoreUser } from "../identity/schema.js"; + +/** + * Core-owned device identity: paired display devices, the permissions granted to them, and the + * short-lived pairing requests that create them (ADR 0036). + * + * **Devices are not users.** There is deliberately no foreign key from a device to + * `ekklesia_core_user`, and no device row ever appears in the identity tables: a device is its own + * principal class with its own credential and its own explicit permissions. The only reference to a + * user here is *who approved a pairing*, which is audit information about a person's action, not a + * device's identity. + * + * Device permissions live in their own table rather than in `ekklesia_core_grant`. That table's + * `user_id` is a non-null foreign key into the user table, and generalizing it to a polymorphic + * subject would rewrite the platform's central authorization record — and its relationship-derived + * grants, audit semantics, and indexes — to serve one narrow case. A separate table keeps the + * device story additive and auditable while the evaluator remains the single place any decision is + * made (`core/src/authorization/evaluator.ts`). + */ + +export const ekklesiaCoreDevice = pgTable( + "ekklesia_core_device", + { + id: uuid("id").primaryKey().defaultRandom(), + /** Assigned by the approving administrator, never self-asserted by the unpaired device. */ + name: text("name").notNull(), + /** + * SHA-256 of the device's credential, hex encoded. The raw credential exists only in the + * response that sets the cookie and in the device's own cookie jar; it is never stored, logged, + * or recoverable from this row. A fast hash is right here — unlike a password, the credential + * is 256 bits of CSPRNG output, so there is nothing to brute-force and a slow KDF would only + * add latency to every request a display makes. + */ + credentialHash: text("credential_hash").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + createdByUserId: text("created_by_user_id").references(() => ekklesiaCoreUser.id, { + onDelete: "set null", + }), + /** Coarse operational signal, throttled by the resolver; never an identity input. */ + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), + /** Server-side authority. Revocation is this column, never the deletion of a remote cookie. */ + revokedAt: timestamp("revoked_at", { withTimezone: true }), + revokedByUserId: text("revoked_by_user_id").references(() => ekklesiaCoreUser.id, { + onDelete: "set null", + }), + }, + (table) => [ + // Every credential is distinct, and the resolver looks a presented credential up by this exact + // hash — an index probe rather than a scan-and-compare, so there is no comparison to time. + uniqueIndex("ekklesia_core_device_credential_hash_idx").on(table.credentialHash), + index("ekklesia_core_device_revoked_idx").on(table.revokedAt), + check("ekklesia_core_device_name_check", sql`length(${table.name}) between 1 and 128`), + check("ekklesia_core_device_credential_hash_check", sql`length(${table.credentialHash}) = 64`), + ], +); + +export const ekklesiaCoreDevicePermission = pgTable( + "ekklesia_core_device_permission", + { + id: uuid("id").primaryKey().defaultRandom(), + deviceId: uuid("device_id") + .notNull() + .references(() => ekklesiaCoreDevice.id, { onDelete: "cascade" }), + permissionId: text("permission_id").notNull(), + grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(), + grantedByUserId: text("granted_by_user_id").references(() => ekklesiaCoreUser.id, { + onDelete: "set null", + }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + }, + (table) => [ + index("ekklesia_core_device_permission_lookup_idx").on(table.deviceId, table.permissionId), + check( + "ekklesia_core_device_permission_id_check", + sql`length(${table.permissionId}) between 1 and 128`, + ), + ], +); + +/** + * One in-flight pairing attempt. + * + * Three distinct secrets meet here and must not be confused: + * + * - `code` is the short digits shown on the screen. It is **public by design** — it is displayed on + * a television in a hallway — and it identifies the request to an administrator. It is stored in + * the clear because it is not a secret and because an administrator looks a request up by it. + * - `claimSecretHash` is SHA-256 of a 256-bit secret held only in the requesting browser's + * HttpOnly cookie. It answers "are you the device that asked?", which the code cannot: anyone who + * can *see* the screen knows the code, so a flow that issued a credential on the strength of the + * code alone would hand the device's identity to whoever read it off the wall. + * - The device credential does not exist yet, and is created only when the approved request is + * consumed by the browser holding the claim secret. + * + * `expiresAt` is the only expiry authority; there is no `expired` state to write and therefore no + * sweeper that must run for correctness. `state` records only what someone *did* — nothing here + * ages a row on its own. + */ +export const ekklesiaCoreDevicePairingRequest = pgTable( + "ekklesia_core_device_pairing_request", + { + id: uuid("id").primaryKey().defaultRandom(), + code: text("code").notNull(), + claimSecretHash: text("claim_secret_hash").notNull(), + /** The display the device actually navigated to, resolved server-side from the publication + * registry. Never a value the browser chose. */ + moduleId: text("module_id").notNull(), + /** Derived from that module's validated display declaration, so a device cannot ask for a + * permission of its choosing. */ + permissionId: text("permission_id").notNull(), + state: text("state").notNull().default("pending"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + /** Chosen by the approving administrator; carried here until the request is consumed and the + * device row is created. */ + deviceName: text("device_name"), + approvedAt: timestamp("approved_at", { withTimezone: true }), + approvedByUserId: text("approved_by_user_id").references(() => ekklesiaCoreUser.id, { + onDelete: "set null", + }), + consumedAt: timestamp("consumed_at", { withTimezone: true }), + deviceId: uuid("device_id").references(() => ekklesiaCoreDevice.id, { onDelete: "set null" }), + }, + (table) => [ + // At most one live request may hold a given code, which is what makes a short code safe to + // generate: a collision is refused by PostgreSQL rather than resolved by whichever row a query + // happened to return first. Consumed rows keep their code without blocking reuse later. + uniqueIndex("ekklesia_core_device_pairing_code_idx") + .on(table.code) + .where(sql`${table.state} <> 'consumed'`), + index("ekklesia_core_device_pairing_claim_idx").on(table.claimSecretHash), + check("ekklesia_core_device_pairing_code_check", sql`${table.code} ~ '^[0-9]{6}$'`), + check( + "ekklesia_core_device_pairing_claim_hash_check", + sql`length(${table.claimSecretHash}) = 64`, + ), + check( + "ekklesia_core_device_pairing_state_check", + sql`${table.state} in ('pending', 'approved', 'consumed')`, + ), + ], +); + +export const ekklesiaCoreDeviceRelations = relations(ekklesiaCoreDevice, ({ many }) => ({ + permissions: many(ekklesiaCoreDevicePermission), +})); + +export const ekklesiaCoreDevicePermissionRelations = relations( + ekklesiaCoreDevicePermission, + ({ one }) => ({ + device: one(ekklesiaCoreDevice, { + fields: [ekklesiaCoreDevicePermission.deviceId], + references: [ekklesiaCoreDevice.id], + }), + }), +); + +/** The device tables, bundled exactly like `authorizationSchema`, so a Drizzle view over the + * platform's shared pool is typed against them. */ +export const deviceSchema = { + ekklesiaCoreDevice, + ekklesiaCoreDevicePairingRequest, + ekklesiaCoreDevicePermission, + ekklesiaCoreDevicePermissionRelations, + ekklesiaCoreDeviceRelations, +}; diff --git a/core/src/devices/store.ts b/core/src/devices/store.ts new file mode 100644 index 0000000..5d3ffa2 --- /dev/null +++ b/core/src/devices/store.ts @@ -0,0 +1,552 @@ +import { createHash, randomBytes, randomInt } from "node:crypto"; +import type { EkklesiaDeviceId, EkklesiaUserId, PermissionId } from "@ekklesia/contracts"; +import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; +import type { DeviceDrizzleDatabase } from "./database.js"; +import { + ekklesiaCoreDevice, + ekklesiaCoreDevicePairingRequest, + ekklesiaCoreDevicePermission, +} from "./schema.js"; + +export type DeviceStoreErrorCode = + | "DEVICE_PAIRING_CODE_UNAVAILABLE" + | "DEVICE_STORE_FAILED" + | "DEVICE_NOT_FOUND"; + +const deviceStoreMessages: Readonly> = Object.freeze({ + DEVICE_NOT_FOUND: "The device does not exist.", + DEVICE_PAIRING_CODE_UNAVAILABLE: "A pairing code could not be allocated. Try again.", + DEVICE_STORE_FAILED: "The device operation could not be completed.", +}); + +/** Never carries a credential, a claim secret, SQL, a driver error, or a cause. */ +export class DeviceStoreError extends Error { + readonly code: DeviceStoreErrorCode; + + constructor(code: DeviceStoreErrorCode) { + super(deviceStoreMessages[code]); + this.name = "DeviceStoreError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +/** + * How long a pairing code is worth showing on a screen. + * + * Long enough that someone can walk from the display to a computer and type six digits; short + * enough that a code photographed off a wall is worthless by the time anyone acts on it. It is not + * the only thing protecting pairing — approval requires an authenticated administrator holding + * `core.devices.manage`, and the credential is issued only to the browser holding the claim secret + * — so this bound is defence in depth rather than the defence. + */ +export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000; + +/** + * How long a device credential is accepted before the device must pair again. + * + * A display runs unattended for months; forcing a signage player to be re-paired every fortnight + * because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen + * on a schedule. What makes a long horizon safe is that it is not the security boundary: the server + * decides on every single request whether the device is still revoked, so an administrator's revoke + * takes effect immediately regardless of how long the credential would otherwise have lived. The + * expiry exists so an abandoned, forgotten screen eventually stops being an identity at all. + */ +export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000; + +/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling + * realtime and assets would otherwise turn an operational nicety into a continuous write load. */ +export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000; + +const PAIRING_CODE_ALLOCATION_ATTEMPTS = 8; + +/** + * How many pairing requests may be waiting at once, across the whole installation. + * + * Starting a pairing needs no identity — an unpaired screen has none to offer — so without a ceiling + * anyone who can reach the platform could fill this table. A building has a handful of screens and + * pairs them one at a time, so this is far above any real use and still a hard bound. Expired rows + * are swept on the way in, so the ceiling counts genuinely live requests rather than history. + */ +const MAX_LIVE_PAIRING_REQUESTS = 50; + +/** SHA-256, hex. Applied to 256-bit CSPRNG output, never to anything user-chosen. */ +export function hashDeviceSecret(secret: string): string { + return createHash("sha256").update(secret, "utf8").digest("hex"); +} + +/** 256 bits, URL-safe. Used for both the pairing claim secret and the device credential — the two + * values that must never be guessable. */ +function generateSecret(): string { + return randomBytes(32).toString("base64url"); +} + +/** Six digits from a rejection-free CSPRNG range, zero padded so every code is exactly six + * characters and none is easier to type than another. */ +function generatePairingCode(): string { + return randomInt(0, 1_000_000).toString().padStart(6, "0"); +} + +export interface CreatePairingRequestInput { + readonly moduleId: string; + readonly permissionId: PermissionId; + readonly now: Date; +} + +export interface CreatedPairingRequest { + readonly code: string; + readonly expiresAt: Date; + /** Returned exactly once, to be written into the device's HttpOnly cookie and never persisted or + * logged in this form. */ + readonly claimSecret: string; +} + +export type PairingRequestStatus = "pending" | "approved" | "expired" | "unknown"; + +export interface PairingRequestView { + readonly moduleId: string; + readonly permissionId: string; + readonly createdAt: Date; + readonly expiresAt: Date; + readonly status: "pending" | "approved"; +} + +export interface ConsumedPairing { + readonly deviceId: EkklesiaDeviceId; + /** Returned exactly once, to be written into the device's HttpOnly cookie. */ + readonly credential: string; + readonly expiresAt: Date; +} + +export interface DeviceSummary { + readonly id: EkklesiaDeviceId; + readonly name: string; + readonly createdAt: Date; + readonly lastSeenAt?: Date; + readonly revokedAt?: Date; + readonly permissions: readonly string[]; +} + +export interface ResolvedDevice { + readonly deviceId: EkklesiaDeviceId; + readonly lastSeenAt?: Date; +} + +export interface DeviceStore { + createPairingRequest(input: CreatePairingRequestInput): Promise; + /** Looks a live request up by the code an administrator typed. Never used by the device itself. */ + findPendingByCode(code: string, now: Date): Promise; + approvePairingRequest(input: { + readonly code: string; + readonly deviceName: string; + readonly approvedBy: EkklesiaUserId; + readonly now: Date; + }): Promise; + /** The device's own poll. Consumes an approved request atomically and issues the credential. */ + claimPairingRequest(input: { + readonly claimSecret: string; + readonly now: Date; + }): Promise<{ readonly status: PairingRequestStatus; readonly paired?: ConsumedPairing }>; + /** Resolves a presented credential to a live, unrevoked device. */ + resolveCredential(credential: string, now: Date): Promise; + touchLastSeen(deviceId: EkklesiaDeviceId, now: Date): Promise; + hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise; + listDevices(): Promise; + revokeDevice(input: { + readonly deviceId: EkklesiaDeviceId; + readonly revokedBy: EkklesiaUserId; + readonly now: Date; + }): Promise; +} + +function rethrowAsStoreError(error: unknown): never { + if (error instanceof DeviceStoreError) { + throw error; + } + throw new DeviceStoreError("DEVICE_STORE_FAILED"); +} + +export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore { + /** Deletes rows that can no longer do anything: expired-and-never-approved requests, and + * consumed ones past their window. Run opportunistically on request creation rather than by a + * scheduler — pairing is rare, so the only moment the table can grow is also a fine moment to + * trim it, and nothing depends on it having run. */ + const sweepDeadRequests = async (now: Date): Promise => { + await db + .delete(ekklesiaCoreDevicePairingRequest) + .where( + or( + and( + lt(ekklesiaCoreDevicePairingRequest.expiresAt, now), + eq(ekklesiaCoreDevicePairingRequest.state, "pending"), + ), + lt(ekklesiaCoreDevicePairingRequest.expiresAt, new Date(now.getTime() - 86_400_000)), + ), + ) + .catch(() => undefined); + }; + + const store: DeviceStore = { + async approvePairingRequest(input): Promise { + try { + // Conditional on `pending` and unexpired, so approving twice, approving an expired request, + // or approving one already consumed all affect zero rows rather than racing. + const rows = await db + .update(ekklesiaCoreDevicePairingRequest) + .set({ + approvedAt: input.now, + approvedByUserId: input.approvedBy, + deviceName: input.deviceName, + state: "approved", + }) + .where( + and( + eq(ekklesiaCoreDevicePairingRequest.code, input.code), + eq(ekklesiaCoreDevicePairingRequest.state, "pending"), + gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now), + ), + ) + .returning({ + createdAt: ekklesiaCoreDevicePairingRequest.createdAt, + expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt, + moduleId: ekklesiaCoreDevicePairingRequest.moduleId, + permissionId: ekklesiaCoreDevicePairingRequest.permissionId, + }); + const row = rows[0]; + return row === undefined + ? undefined + : Object.freeze({ ...row, status: "approved" as const }); + } catch (error) { + rethrowAsStoreError(error); + } + }, + + async claimPairingRequest(input): Promise<{ + readonly status: PairingRequestStatus; + readonly paired?: ConsumedPairing; + }> { + const claimSecretHash = hashDeviceSecret(input.claimSecret); + try { + return await db.transaction(async (tx) => { + const rows = await tx + .select({ + deviceName: ekklesiaCoreDevicePairingRequest.deviceName, + expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt, + id: ekklesiaCoreDevicePairingRequest.id, + permissionId: ekklesiaCoreDevicePairingRequest.permissionId, + state: ekklesiaCoreDevicePairingRequest.state, + approvedByUserId: ekklesiaCoreDevicePairingRequest.approvedByUserId, + }) + .from(ekklesiaCoreDevicePairingRequest) + .where(eq(ekklesiaCoreDevicePairingRequest.claimSecretHash, claimSecretHash)) + .limit(1); + const request = rows[0]; + if (!request) { + return { status: "unknown" as const }; + } + if (request.state === "consumed") { + // A credential was already issued for this request. Replaying the claim never issues a + // second one. + return { status: "unknown" as const }; + } + if (request.expiresAt.getTime() <= input.now.getTime()) { + return { status: "expired" as const }; + } + if (request.state !== "approved") { + return { status: "pending" as const }; + } + + // The single-use gate: only the transaction whose UPDATE actually matches the still + // `approved` row proceeds to mint a credential. A concurrent second claim matches zero + // rows and is told the request is gone. + const consumed = await tx + .update(ekklesiaCoreDevicePairingRequest) + .set({ consumedAt: input.now, state: "consumed" }) + .where( + and( + eq(ekklesiaCoreDevicePairingRequest.id, request.id), + eq(ekklesiaCoreDevicePairingRequest.state, "approved"), + ), + ) + .returning({ id: ekklesiaCoreDevicePairingRequest.id }); + if (consumed.length === 0) { + return { status: "unknown" as const }; + } + + const credential = generateSecret(); + const created = await tx + .insert(ekklesiaCoreDevice) + .values({ + createdByUserId: request.approvedByUserId, + credentialHash: hashDeviceSecret(credential), + name: request.deviceName ?? "Display device", + }) + .returning({ id: ekklesiaCoreDevice.id }); + const deviceId = created[0]?.id; + if (deviceId === undefined) { + throw new DeviceStoreError("DEVICE_STORE_FAILED"); + } + + // Exactly the one permission the request was created for — never a bundle, never every + // permission the module declares. + await tx.insert(ekklesiaCoreDevicePermission).values({ + deviceId, + grantedByUserId: request.approvedByUserId, + permissionId: request.permissionId, + }); + + await tx + .update(ekklesiaCoreDevicePairingRequest) + .set({ deviceId }) + .where(eq(ekklesiaCoreDevicePairingRequest.id, request.id)); + + return { + paired: Object.freeze({ + credential, + deviceId: deviceId as EkklesiaDeviceId, + expiresAt: new Date(input.now.getTime() + DEVICE_CREDENTIAL_TTL_MS), + }), + status: "pending" as const, + }; + }); + } catch (error) { + rethrowAsStoreError(error); + } + }, + + async createPairingRequest(input): Promise { + await sweepDeadRequests(input.now); + const live = await db + .select({ id: ekklesiaCoreDevicePairingRequest.id }) + .from(ekklesiaCoreDevicePairingRequest) + .where( + and( + gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now), + eq(ekklesiaCoreDevicePairingRequest.state, "pending"), + ), + ) + .limit(MAX_LIVE_PAIRING_REQUESTS); + if (live.length >= MAX_LIVE_PAIRING_REQUESTS) { + throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE"); + } + const claimSecret = generateSecret(); + const expiresAt = new Date(input.now.getTime() + DEVICE_PAIRING_REQUEST_TTL_MS); + // Collisions are resolved by the partial unique index, not by reading first and hoping: a + // concurrent request that took the same code makes this insert fail and we simply draw again. + for (let attempt = 0; attempt < PAIRING_CODE_ALLOCATION_ATTEMPTS; attempt += 1) { + const code = generatePairingCode(); + try { + await db.insert(ekklesiaCoreDevicePairingRequest).values({ + claimSecretHash: hashDeviceSecret(claimSecret), + code, + expiresAt, + moduleId: input.moduleId, + permissionId: input.permissionId, + }); + return Object.freeze({ claimSecret, code, expiresAt }); + } catch { + // Fall through and draw a different code. + } + } + throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE"); + }, + + async findPendingByCode(code, now): Promise { + try { + const rows = await db + .select({ + createdAt: ekklesiaCoreDevicePairingRequest.createdAt, + expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt, + moduleId: ekklesiaCoreDevicePairingRequest.moduleId, + permissionId: ekklesiaCoreDevicePairingRequest.permissionId, + state: ekklesiaCoreDevicePairingRequest.state, + }) + .from(ekklesiaCoreDevicePairingRequest) + .where( + and( + eq(ekklesiaCoreDevicePairingRequest.code, code), + eq(ekklesiaCoreDevicePairingRequest.state, "pending"), + gt(ekklesiaCoreDevicePairingRequest.expiresAt, now), + ), + ) + .limit(1); + const row = rows[0]; + return row === undefined + ? undefined + : Object.freeze({ + createdAt: row.createdAt, + expiresAt: row.expiresAt, + moduleId: row.moduleId, + permissionId: row.permissionId, + status: "pending" as const, + }); + } catch (error) { + rethrowAsStoreError(error); + } + }, + + async hasActivePermission(deviceId, permission): Promise { + try { + // Joined against the device row so a revoked device has no permissions at all, without + // every permission row needing to be revoked separately. + const rows = await db + .select({ id: ekklesiaCoreDevicePermission.id }) + .from(ekklesiaCoreDevicePermission) + .innerJoin( + ekklesiaCoreDevice, + eq(ekklesiaCoreDevice.id, ekklesiaCoreDevicePermission.deviceId), + ) + .where( + and( + eq(ekklesiaCoreDevicePermission.deviceId, deviceId), + eq(ekklesiaCoreDevicePermission.permissionId, permission), + isNull(ekklesiaCoreDevicePermission.revokedAt), + isNull(ekklesiaCoreDevice.revokedAt), + ), + ) + .limit(1); + return rows.length > 0; + } catch (error) { + rethrowAsStoreError(error); + } + }, + + async listDevices(): Promise { + try { + const devices = await db + .select({ + createdAt: ekklesiaCoreDevice.createdAt, + id: ekklesiaCoreDevice.id, + lastSeenAt: ekklesiaCoreDevice.lastSeenAt, + name: ekklesiaCoreDevice.name, + revokedAt: ekklesiaCoreDevice.revokedAt, + }) + .from(ekklesiaCoreDevice) + .orderBy(ekklesiaCoreDevice.createdAt); + const permissions = await db + .select({ + deviceId: ekklesiaCoreDevicePermission.deviceId, + permissionId: ekklesiaCoreDevicePermission.permissionId, + }) + .from(ekklesiaCoreDevicePermission) + .where(isNull(ekklesiaCoreDevicePermission.revokedAt)); + return Object.freeze( + devices.map((device) => + Object.freeze({ + createdAt: device.createdAt, + id: device.id as EkklesiaDeviceId, + name: device.name, + permissions: Object.freeze( + permissions + .filter((permission) => permission.deviceId === device.id) + .map((permission) => permission.permissionId) + .sort(), + ), + ...(device.lastSeenAt === null ? {} : { lastSeenAt: device.lastSeenAt }), + ...(device.revokedAt === null ? {} : { revokedAt: device.revokedAt }), + }), + ), + ); + } catch (error) { + rethrowAsStoreError(error); + } + }, + + async resolveCredential(credential, now): Promise { + try { + const rows = await db + .select({ + createdAt: ekklesiaCoreDevice.createdAt, + id: ekklesiaCoreDevice.id, + lastSeenAt: ekklesiaCoreDevice.lastSeenAt, + }) + .from(ekklesiaCoreDevice) + .where( + and( + eq(ekklesiaCoreDevice.credentialHash, hashDeviceSecret(credential)), + isNull(ekklesiaCoreDevice.revokedAt), + ), + ) + .limit(1); + const row = rows[0]; + if (!row) { + return undefined; + } + // The credential's own horizon, measured from when the device was paired. Revocation is + // checked above and is what makes a long horizon acceptable. + if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) { + return undefined; + } + return Object.freeze({ + deviceId: row.id as EkklesiaDeviceId, + ...(row.lastSeenAt === null ? {} : { lastSeenAt: row.lastSeenAt }), + }); + } catch (error) { + rethrowAsStoreError(error); + } + }, + + async revokeDevice(input): Promise { + try { + const rows = await db + .update(ekklesiaCoreDevice) + .set({ revokedAt: input.now, revokedByUserId: input.revokedBy }) + .where( + and(eq(ekklesiaCoreDevice.id, input.deviceId), isNull(ekklesiaCoreDevice.revokedAt)), + ) + .returning({ id: ekklesiaCoreDevice.id }); + if (rows.length === 0) { + // Either no such device or already revoked. Both are "not an active device", and telling + // the two apart would only serve enumeration. + const existing = await db + .select({ id: ekklesiaCoreDevice.id }) + .from(ekklesiaCoreDevice) + .where(eq(ekklesiaCoreDevice.id, input.deviceId)) + .limit(1); + if (existing.length === 0) { + throw new DeviceStoreError("DEVICE_NOT_FOUND"); + } + return; + } + await db + .update(ekklesiaCoreDevicePermission) + .set({ revokedAt: input.now }) + .where( + and( + eq(ekklesiaCoreDevicePermission.deviceId, input.deviceId), + isNull(ekklesiaCoreDevicePermission.revokedAt), + ), + ); + } catch (error) { + rethrowAsStoreError(error); + } + }, + + async touchLastSeen(deviceId, now): Promise { + // Throttled in SQL so concurrent requests from one display cannot each decide to write. + await db + .update(ekklesiaCoreDevice) + .set({ lastSeenAt: now }) + .where( + and( + eq(ekklesiaCoreDevice.id, deviceId), + or( + isNull(ekklesiaCoreDevice.lastSeenAt), + lt( + ekklesiaCoreDevice.lastSeenAt, + new Date(now.getTime() - DEVICE_LAST_SEEN_THROTTLE_MS), + ), + ), + ), + ) + .catch(() => undefined); + }, + }; + return Object.freeze(store); +} + +/** Exported for the schema-drift check and tests; never used to build a query string by hand. */ +export const DEVICE_PAIRING_CODE_PATTERN = /^[0-9]{6}$/u; + +export { sql as deviceStoreSql }; diff --git a/core/tests/authorization-device.test.ts b/core/tests/authorization-device.test.ts new file mode 100644 index 0000000..be21a70 --- /dev/null +++ b/core/tests/authorization-device.test.ts @@ -0,0 +1,189 @@ +import { + ANONYMOUS_PRINCIPAL, + createAuthenticatedPrincipal, + createDevicePrincipal, + parsePermissionId, +} from "@ekklesia/contracts"; +import { describe, expect, it, vi } from "vitest"; +import { createAuthorizationEvaluator } from "../src/authorization/evaluator.js"; + +/** + * Device authority, decided by the **same** evaluator that decides everything else. + * + * The point of these tests is mostly negative: a device must not acquire authority from being a + * principal, from being paired, from an `authenticated` baseline, or from platform ownership. The + * only thing that ever makes a device allowed is an explicit permission granted to that exact + * device (ADR 0036). + */ +const displayPermission = parsePermissionId("announcements.slideshow.view"); +const otherPermission = parsePermissionId("calendar.display.view"); +const deviceId = "3f1c8a4e-1d2b-4c3a-9e5f-6a7b8c9d0e1f"; + +function evaluator(options: { readonly devicePermissions?: readonly string[] } = {}) { + const granted = new Set(options.devicePermissions ?? []); + const hasActivePermission = vi.fn(async (_deviceId: string, permission: string) => + granted.has(permission), + ); + // Deliberately always true: if the evaluator ever consulted these for a device, every assertion + // below would flip, which is exactly what must not happen. + const isPlatformOwner = vi.fn(async () => true); + const hasActiveGrant = vi.fn(async () => true); + return { + evaluator: createAuthorizationEvaluator({ + devices: { hasActivePermission } as never, + grants: { hasActiveGrant } as never, + platformOwners: { isPlatformOwner } as never, + }), + hasActiveGrant, + hasActivePermission, + isPlatformOwner, + }; +} + +describe("device principals go through the ordinary evaluator", () => { + it("allows a device holding the exact restricted permission", async () => { + const harness = evaluator({ devicePermissions: [displayPermission] }); + + await expect( + harness.evaluator.authorize({ + baseline: "restricted", + permission: displayPermission, + principal: createDevicePrincipal(deviceId), + }), + ).resolves.toEqual({ allowed: true }); + expect(harness.hasActivePermission).toHaveBeenCalledWith(deviceId, displayPermission); + }); + + it("denies a device any permission it was not granted", async () => { + const harness = evaluator({ devicePermissions: [displayPermission] }); + + // Pairing a screen for one display is not authority over another module's. + await expect( + harness.evaluator.authorize({ + baseline: "restricted", + permission: otherPermission, + principal: createDevicePrincipal(deviceId), + }), + ).resolves.toEqual({ allowed: false }); + }); + + it("never satisfies an authenticated baseline, however many permissions it holds", async () => { + const harness = evaluator({ devicePermissions: [displayPermission] }); + + // "A person is signed in" is not true of a television. Every route that settles for the + // `authenticated` baseline — the module UI inventory among them — stays closed. + await expect( + harness.evaluator.authorize({ + baseline: "authenticated", + permission: displayPermission, + principal: createDevicePrincipal(deviceId), + }), + ).resolves.toEqual({ allowed: false }); + expect(harness.hasActivePermission).not.toHaveBeenCalled(); + }); + + it("never consults platform ownership or user grants for a device", async () => { + const harness = evaluator(); + + await expect( + harness.evaluator.authorize({ + baseline: "restricted", + permission: displayPermission, + principal: createDevicePrincipal(deviceId), + }), + ).resolves.toEqual({ allowed: false }); + + // Both ports would have answered "yes". A device is denied anyway, because neither is a path a + // device can reach — there is no device that is a platform owner. + expect(harness.isPlatformOwner).not.toHaveBeenCalled(); + expect(harness.hasActiveGrant).not.toHaveBeenCalled(); + }); + + it("denies every device when the deployment composed no device support at all", async () => { + const withoutDevices = createAuthorizationEvaluator({ + grants: { hasActiveGrant: async () => true } as never, + platformOwners: { isPlatformOwner: async () => true } as never, + }); + + await expect( + withoutDevices.authorize({ + baseline: "restricted", + permission: displayPermission, + principal: createDevicePrincipal(deviceId), + }), + ).resolves.toEqual({ allowed: false }); + }); + + it("denies a scoped check for a device rather than treating it as unscoped", async () => { + const harness = evaluator({ devicePermissions: [displayPermission] }); + + await expect( + harness.evaluator.authorize({ + baseline: "restricted", + permission: displayPermission, + principal: createDevicePrincipal(deviceId), + scope: "event-1", + }), + ).resolves.toEqual({ allowed: false }); + }); + + it("still allows a device through a public baseline, like any other principal", async () => { + const harness = evaluator(); + + await expect( + harness.evaluator.authorize({ + baseline: "public", + permission: displayPermission, + principal: createDevicePrincipal(deviceId), + }), + ).resolves.toEqual({ allowed: true }); + }); + + it("leaves human and anonymous decisions exactly as they were", async () => { + const harness = evaluator(); + + await expect( + harness.evaluator.authorize({ + baseline: "restricted", + permission: displayPermission, + principal: createAuthenticatedPrincipal("user-1"), + }), + ).resolves.toEqual({ allowed: true }); + await expect( + harness.evaluator.authorize({ + baseline: "restricted", + permission: displayPermission, + principal: ANONYMOUS_PRINCIPAL, + }), + ).resolves.toEqual({ allowed: false }); + await expect( + harness.evaluator.authorize({ + baseline: "authenticated", + permission: displayPermission, + principal: createAuthenticatedPrincipal("user-1"), + }), + ).resolves.toEqual({ allowed: true }); + }); + + it("propagates a device lookup failure rather than reporting it as denied", async () => { + const failing = createAuthorizationEvaluator({ + devices: { + hasActivePermission: async () => { + throw new Error("database unavailable"); + }, + } as never, + grants: { hasActiveGrant: async () => false } as never, + platformOwners: { isPlatformOwner: async () => false } as never, + }); + + // A caller must never be told "no permission" when the true answer is "could not be + // determined", exactly as for a human. + await expect( + failing.authorize({ + baseline: "restricted", + permission: displayPermission, + principal: createDevicePrincipal(deviceId), + }), + ).rejects.toMatchObject({ code: "AUTHORIZATION_EVALUATION_FAILED" }); + }); +}); diff --git a/core/tests/authorization-permissions.test.ts b/core/tests/authorization-permissions.test.ts index 369923c..97b8b51 100644 --- a/core/tests/authorization-permissions.test.ts +++ b/core/tests/authorization-permissions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE, + CORE_DEVICES_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_UI_READ, @@ -132,13 +133,14 @@ describe("CORE_PERMISSION_REGISTRY", () => { expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE); }); - it("contains exactly these five permissions and no others", () => { + it("contains exactly these six permissions and no others", () => { expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([ CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_MODULES_UI_READ, CORE_DASHBOARD_NAVIGATION_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE, + CORE_DEVICES_MANAGE, ]); }); }); @@ -171,6 +173,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => { description: "Read and change an installed module's own configuration values.", id: CORE_MODULES_CONFIGURATION_MANAGE, }, + { + baseline: "restricted", + description: "Approve display device pairing, list paired devices, and revoke a device.", + id: CORE_DEVICES_MANAGE, + }, ]); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true); for (const declaration of CORE_PERMISSION_DECLARATIONS) { diff --git a/core/tests/authorization-store.integration.ts b/core/tests/authorization-store.integration.ts index 2f1ca35..c4e4bc8 100644 --- a/core/tests/authorization-store.integration.ts +++ b/core/tests/authorization-store.integration.ts @@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", "core:0005_module_configuration", + "core:0006_device_identity", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -228,6 +229,11 @@ describe.sequential("live Core authorization store", () => { "ekklesia_core_authorization_audit", "ekklesia_core_dashboard_group", "ekklesia_core_dashboard_placement", + // Device identity is Core's, and deliberately its own tables rather than rows smuggled into + // the user or grant tables (ADR 0036). + "ekklesia_core_device", + "ekklesia_core_device_pairing_request", + "ekklesia_core_device_permission", "ekklesia_core_grant", "ekklesia_core_migrations", "ekklesia_core_module_configuration", diff --git a/core/tests/dashboard-store.integration.ts b/core/tests/dashboard-store.integration.ts index 0406684..9fd5e2a 100644 --- a/core/tests/dashboard-store.integration.ts +++ b/core/tests/dashboard-store.integration.ts @@ -175,6 +175,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", "core:0005_module_configuration", + "core:0006_device_identity", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index 5911974..caf6b90 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -87,7 +87,7 @@ Modules ✓ Dependency graph valid Dependency order is empty. ✓ Permission registry composed - 5 permissions registered + 6 permissions registered ○ Module lifecycle skipped DATABASE_URL is not configured. ○ Module configuration skipped diff --git a/core/tests/module-configuration-store.integration.ts b/core/tests/module-configuration-store.integration.ts index d9e6cf7..d8aa6a5 100644 --- a/core/tests/module-configuration-store.integration.ts +++ b/core/tests/module-configuration-store.integration.ts @@ -3,8 +3,15 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { parseModuleManifest } from "@ekklesia/contracts"; import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it } from "vitest"; +import { createValidatedPostgresPool } from "../src/database/index.js"; +import { + applyModuleMigrations, + createPostgresMigrationDatabase, + discoverAllMigrations, +} from "../src/migrations/index.js"; import { createModuleConfigurationStore, createModuleConfigurationView, @@ -12,13 +19,6 @@ import { ModuleConfigurationStoreError, resolveModuleConfigurationFor, } from "../src/modules/index.js"; -import { parseModuleManifest } from "@ekklesia/contracts"; -import { createValidatedPostgresPool } from "../src/database/index.js"; -import { - applyModuleMigrations, - createPostgresMigrationDatabase, - discoverAllMigrations, -} from "../src/migrations/index.js"; /** * The module configuration store against real PostgreSQL. @@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", "core:0005_module_configuration", + "core:0006_device_identity", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/module-lifecycle-store.integration.ts b/core/tests/module-lifecycle-store.integration.ts index b2986c0..f03fc72 100644 --- a/core/tests/module-lifecycle-store.integration.ts +++ b/core/tests/module-lifecycle-store.integration.ts @@ -3,8 +3,16 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { parseModuleManifest } from "@ekklesia/contracts"; import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it } from "vitest"; +import { createValidatedPostgresPool } from "../src/database/index.js"; +import { + applyModuleMigrations, + createPostgresMigrationDatabase, + discoverAllMigrations, +} from "../src/migrations/index.js"; +import type { DiscoveredModule } from "../src/modules/discovery.js"; import { createModuleLifecycleStore, createModuleLifecycleView, @@ -12,14 +20,6 @@ import { ModuleLifecycleStoreError, resolveModuleEligibility, } from "../src/modules/index.js"; -import { parseModuleManifest } from "@ekklesia/contracts"; -import type { DiscoveredModule } from "../src/modules/discovery.js"; -import { createValidatedPostgresPool } from "../src/database/index.js"; -import { - applyModuleMigrations, - createPostgresMigrationDatabase, - discoverAllMigrations, -} from "../src/migrations/index.js"; /** * The module lifecycle configuration store against real PostgreSQL. @@ -180,6 +180,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", "core:0005_module_configuration", + "core:0006_device_identity", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/module-permissions.test.ts b/core/tests/module-permissions.test.ts index 0e722a4..ed39265 100644 --- a/core/tests/module-permissions.test.ts +++ b/core/tests/module-permissions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE, + CORE_DEVICES_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_UI_READ, @@ -74,7 +75,7 @@ describe("composePlatformPermissionRegistry", () => { expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true); expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true); // Core's own five declarations plus one from each module. - expect(registry.declarations).toHaveLength(7); + expect(registry.declarations).toHaveLength(8); }); it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => { @@ -94,6 +95,7 @@ describe("composePlatformPermissionRegistry", () => { CORE_DASHBOARD_NAVIGATION_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE, + CORE_DEVICES_MANAGE, "calendar.events.read", "calendar.events.write", "people.directory.read", diff --git a/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md b/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md new file mode 100644 index 0000000..e73bf26 --- /dev/null +++ b/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md @@ -0,0 +1,192 @@ +# 0036: A display device is its own principal, paired by an administrator + +- Status: Accepted +- Date: 2026-08-24 +- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md), + [0032](0032-native-module-realtime-transport.md), + [0033](0033-display-kiosk-host-contract.md), + [0034](0034-host-owned-browser-authentication.md), + [0035](0035-initial-platform-owner-bootstrap.md) + +## Context + +[ADR 0033](0033-display-kiosk-host-contract.md) gave Ekklesia a chrome-free display host, and +[ADR 0034](0034-host-owned-browser-authentication.md) gave it a login page. Put together, the only +way to light up a television in a foyer was to sign a human being into it and leave that session +running on a screen anyone can walk up to. + +That is not a display model. It means a person's credentials live on a wall, a person's session is +what a screen depends on, and revoking that person — or their session simply expiring — takes the +signage down. The alternatives are worse: a public unauthenticated display, a hardcoded token, or a +credential in a URL. + +A screen is not a person, and pretending otherwise is the problem. + +## Decision + +Ekklesia recognizes **three** principal classes: anonymous, authenticated human, and managed device. +A device is a first-class principal with its own identity, its own credential, and its own explicit +permissions. + +```text +screen opens /display/announcements → no identity → shows a short code +administrator types the code in the Dashboard, names the screen, approves +screen collects a credential → device principal → display and realtime work +administrator revokes → the screen loses access and shows a code again +``` + +### Devices are not users + +There is no device row in the identity tables and no foreign key from a device to a user. A device +has no email, no password, and no human session. + +This matters beyond tidiness. Every "is a person signed in?" question in the platform is answered by +`isAuthenticatedPrincipal`, and a device that satisfied it would inherit whatever a signed-in human +may do. So a device satisfies the `authenticated` baseline **never** — not with one permission, not +with ten. The only thing that makes a device allowed is an explicit permission granted to that exact +device. + +### Device permissions are their own table, and the evaluator is still the only judge + +`ekklesia_core_grant.user_id` is a non-null foreign key into the user table. Generalizing it into a +polymorphic subject would rewrite the platform's central authorization record — its +relationship-derived grants, its audit semantics, its indexes — to serve one narrow case. Device +permissions therefore live in `ekklesia_core_device_permission`. + +That is a schema decision, **not** a second decision point. `createAuthorizationEvaluator` gained one +branch, so a device is allowed or denied by the same function, with the same deny-by-default, as +anyone else. There is no `if (device) bypass`, no owner check a device could reach — a device is +never a platform owner — and no baseline that stands in for a grant. A deployment that composed no +device support at all simply denies every device. + +### The pairing code is not the credential + +Three secrets are involved and conflating any two of them would break the model: + +| value | who sees it | what it does | +| --- | --- | --- | +| pairing code (6 digits) | **anyone who can see the screen** | identifies the request to an administrator | +| claim secret (256 bits) | only the requesting browser, HttpOnly | proves "I am the screen that asked" | +| device credential (256 bits) | only the paired browser, HttpOnly | is the device's identity | + +The code is printed across a television in a public hallway. It is therefore treated as public: it +is stored in the clear, and knowing it is worth nothing on its own. Approving it requires an +administrator holding `core.devices.manage`, and **collecting** the resulting credential requires the +claim cookie, which no onlooker has. A flow that issued a credential to whoever presented the code +would hand a screen's identity to anyone who read it off the wall. + +Six digits are therefore not carrying the security of this design. They are bounded anyway: ten +minutes, single-use, one live request per code enforced by a partial unique index, and an +administrator in the loop. + +### The permission comes from the display, never from the request + +A pairing request names only the module whose display the screen 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 its validated display declaration. A browser cannot ask to be granted `core.devices.manage`, +another module's permission, or anything else. Approval grants exactly the one permission the host +derived, never a bundle. + +### State machine + +`pending → approved → consumed`, plus expiry derived from `expires_at`. + +Expiry is deliberately *not* a fourth state: nothing has to run for a code to stop working, so there +is no sweeper whose failure would leave live codes lying around. Each transition is a conditional +`UPDATE` — approval matches only a `pending`, unexpired row, and consumption matches only an +`approved` one — so approving twice, approving something expired, and two screens racing to claim one +approval all affect zero rows rather than racing. Exactly one credential can ever come out of one +code. + +### The credential is hashed, and revocation is server-side + +The device receives an opaque 256-bit credential in an HttpOnly cookie; the server stores only +SHA-256 of it. A fast hash is correct here — unlike a password, there is nothing to brute-force in +CSPRNG output, and a slow KDF would only add latency to every request a display makes. The raw value +exists in exactly two places: the response that sets the cookie, and the device's own cookie jar. It +is never logged. + +Revocation is `revoked_at` on the device row, checked on **every** principal resolution. It is not a +request that a remote browser discard anything: a revoked screen keeps its cookie and stops working. + +### Cookies + +Two host-owned cookies, named nothing like Better Auth's: `ekklesia_device_pairing` (short-lived, +cleared on use) and `ekklesia_device_session`. Both are `HttpOnly` — module display code runs in this +very page and must never be able to read the credential — `SameSite=Strict`, host-only, `Path=/`, and +`Secure` whenever the deployment's own base URL is https. Nothing about the credential is ever +returned in JSON, put in a query string, or written to `localStorage`. + +**A human session always wins.** The device cookie is not even read when Better Auth resolves a +person, so an administrator who opens a display on their laptop stays themself. + +### `/api/v1/principal` stays human-only + +A device is reported as `anonymous` there, deliberately. + +That endpoint is what the Dashboard shell uses to decide whether someone is signed in, and its +shape — "not anonymous means logged in" — was load-bearing in the shell before this pass. Returning a +device from it would have made a television a Dashboard user. The shell's test is now `=== "authenticated"` +as a second lock, but the first lock is that the endpoint never says "device" at all. + +The display host instead reads its viewer from the **descriptor**, which is the display's actual +authorization boundary and already performed the check. So displays are descriptor-first while the +Dashboard remains principal-first, and neither has to infer an identity by elimination. + +### The display page is served to anyone; the descriptor is not + +An unpaired screen has no identity, so a permission gate on the page would leave a new television +with nowhere to render a code. The page is the same inert, module-agnostic bytes for every display — +no module id, no data, one already-public script URL — and every authorization decision stays in the +descriptor. What this does reveal to an anonymous caller is that a module with a display exists at +that path, which any pairing flow must reveal to the screen it is pairing. + +### Session lifetime + +Device credentials last 180 days; human sessions are unchanged. A screen mounted near a ceiling +should not need re-pairing on a fortnightly cycle. What makes a long horizon safe is that it is not +the security boundary — the server re-checks revocation on every request — and the expiry exists so +an abandoned, forgotten screen eventually stops being an identity at all. `lastSeenAt` is updated at +most every 15 minutes, in SQL as well as in the resolver, so a display polling realtime does not +become a write load. + +## Consequences + +### Revocation is prompt, but not instantaneous, and this ADR says so + +A revoked device fails its next HTTP request immediately, and its next realtime *connection* +immediately. An **already open** SSE stream is not torn down at the moment of revocation: realtime +already forces a reconnect every five minutes (ADR 0032), so the worst case is a bounded five-minute +window during which a screen keeps receiving a channel it has just lost. Closing live streams on +revocation would mean reaching into the realtime registry by principal, which is a change to a +subsystem this pass has no other reason to touch. The bound is documented rather than described as +instant. + +### Re-pairing creates a new identity + +A revoked screen that is paired again becomes a **new** device row with a new credential. The old +credential is never reactivated, and the revoked row stays in the list as evidence. Reactivating the +original would mean a revoked credential could come back, which is exactly what revocation must +prevent. + +### What was deliberately not built + +QR codes (a future scanner can encode the same short code — the backend protocol does not change, +and a QR must never carry the credential, an admin token, or a session), per-device content +targeting, device groups, health monitoring, remote control, and mobile dashboards. The pairing API +is not desktop-specific, so a future mobile dashboard host can consume it unchanged. + +### Ekklesia Lite has none of this + +Lite is a minimal trusted-network module runtime: module loading, config, database, storage, HTTP, +realtime, and UI hosting. It deliberately provides no human authentication, no permission +enforcement, and **no managed device identity or pairing** — a Lite-hosted display simply runs on a +trusted local network. Device identity is a full-Ekklesia capability, and nothing here belongs in a +future Lite contract. No module code branches on which host is running it. + +### Module UI is untouched + +A module's display receives a principal and its permissions, exactly as before. The principal union +gained a `device` variant so a module can render honestly for a screen with nobody behind it, and +that is the whole change — there are no pairing controls in `ModuleUiContext`, and a module cannot +approve, list, or revoke anything. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index c627d10..e22246a 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -44,3 +44,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted - [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted - [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted +- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index ed8a4ea..49d936e 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -483,6 +483,18 @@ The permission must have an `authenticated` or `restricted` baseline: public/dev is still intentionally unsolved. The display artifact must be distinct from every other configured entrypoint. Primary UI, display UI, both, and neither are all valid module shapes. +Phase 7B pass 2 makes those screens principals in their own right +([ADR 0036](decisions/0036-managed-device-identity-and-pairing.md)). The platform recognizes +anonymous, authenticated human, and managed device. A device is deliberately not a user — no identity +row, no password, and no satisfaction of the `authenticated` baseline — and its authority is decided +by the one authorization evaluator, from an explicit permission granted to that exact device. +Pairing is administrator-approved: a screen shows a short, public code that identifies its request, +while the credential is issued only to the browser holding a separate HttpOnly claim secret and is +stored only as a digest. Revocation is server-side and checked on every principal resolution; +re-pairing mints a new identity rather than reviving a revoked one. Ekklesia Lite provides module +runtime primitives and deliberately no human authentication, permission enforcement, or managed +device identity, so pairing is a full-Ekklesia concern only. + The two browser roles share the existing `defineModuleUi({ id, version, mount })` export and exactly the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`, `presentation.kind`, or host-identity branch. The canonical builder reads only diff --git a/docs/development/api.md b/docs/development/api.md index 6b52e00..9f560f0 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -826,6 +826,43 @@ still the right choice when the response must be computed, transformed, or autho There are no signed **write** URLs: uploads go through a module route, where the module can validate what it is accepting. +## Display device pairing and management + +Host-owned, and reachable by no module +([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). Two sibling +prefixes: `/api/v1/device-pairing` and `/api/v1/devices`. + +**The screen's side** (no identity required; authority is the HttpOnly claim cookie the first call +sets, never the code): + +| route | purpose | +| --- | --- | +| `POST /api/v1/device-pairing/requests` | Body `{moduleId}`. Returns `{code, expiresAt, pollIntervalMs}` and sets `ekklesia_device_pairing`. The permission is read from that module's validated display declaration — a request cannot name one. | +| `POST /api/v1/device-pairing/claims` | Polls. Returns `{status}`; on `paired` it sets `ekklesia_device_session` and clears the pairing cookie. | + +**The administrator's side** (requires the restricted `core.devices.manage` and an exact trusted +`Origin`): + +| route | purpose | +| --- | --- | +| `POST /api/v1/device-pairing/lookups` | Body `{code}`. Shows the module and permission a screen is asking for, before approving. | +| `POST /api/v1/device-pairing/approvals` | Body `{code, deviceName}`. The administrator names the screen; the screen never names itself. | +| `GET /api/v1/devices` | Paired devices: name, permissions, created, last seen, revoked. Never a credential. | +| `POST /api/v1/devices/:deviceId/revoke` | `204`. Server-side revocation, checked on every principal resolution. | + +The pairing code travels in request **bodies**, never a path or query, so it does not land in access +logs, browser history, or a `Referer`. Expired, consumed, mistyped, and never-existed codes all +answer the same way. + +`/api/v1/principal` deliberately reports a device as `anonymous`: it answers a question about +*people*, and the Dashboard depends on that. A display reads its viewer from the display descriptor +instead, which now carries `viewer: {kind: "authenticated", userId} | {kind: "device", deviceId}`. + +**Cookies.** `ekklesia_device_session` is `HttpOnly`, `SameSite=Strict`, host-only, `Path=/`, and +`Secure` when the identity base URL is https. Deployment note: because it is host-only and +`SameSite=Strict`, a display must be served from the same origin as the API — which the platform's +own `/display/` route is. + ## Module HTTP routes An installed module's routes (declared via `http.route(...)` in its `register` hook — see diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 7e00f67..fecce3b 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -275,6 +275,30 @@ unregistered target permission instead receives the identical sanitized `403 {"code":"AUTHORIZATION_DENIED"}` a genuinely denied caller would, so that response never reveals whether the caller was denied or the permission was simply never registered. +## Device permissions + +A paired display device holds explicit permissions of its own, in `ekklesia_core_device_permission` +rather than in `ekklesia_core_grant` +([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). That table's +`user_id` is a non-null foreign key into the user table, and making it polymorphic would rewrite the +platform's central authorization record to serve one narrow case. + +**It is a separate table, not a separate decision.** `createAuthorizationEvaluator` consults exactly +one authority per call, chosen by principal kind, and a device reaches `allowed` only by holding the +exact permission being checked: + +- `public` — allowed, as for any principal. +- `authenticated` — **denied**, always. A device is not a person. +- `restricted` — allowed only with an active, unrevoked permission for that exact device. + +A device is never a platform owner, and the owner and user-grant ports are not consulted for one at +all. A deployment that composed no device support denies every device. Revoking a device revokes its +permissions with it, and the permission lookup joins the device row so a revoked device has no +permissions even if a row were left behind. + +Approving a pairing grants exactly the one display permission the host derived from the module's +validated display declaration — never a bundle, never a permission the browser named. + ## Platform-owner bootstrap There is no "first user becomes owner" inference anywhere in this repository, and no HTTP diff --git a/docs/development/identity.md b/docs/development/identity.md index 8c6fea7..06d5bf9 100644 --- a/docs/development/identity.md +++ b/docs/development/identity.md @@ -265,6 +265,20 @@ registry) is covered separately in [Authorization](authorization.md). Its bootst an existing identity; it does not create one. Creating the *first* identity on a fresh installation is what `pnpm identity:create-initial-owner` is for — see below. +## Three principal classes + +Ekklesia recognizes **anonymous**, **authenticated** (a person), and **device** (a paired display) +([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). + +A device is not a user: it has no row in the identity tables, no email, no password, and no human +session. `isAuthenticatedPrincipal` returns `false` for it, which is what keeps every "somebody is +signed in" route closed to a screen on a wall. Its credential is a separate HttpOnly cookie +(`ekklesia_device_session`), resolved after Better Auth has been asked first — a human session always +wins, so a browser holding both remains a person. + +`/api/v1/principal` reports a device as `anonymous` on purpose. It is the endpoint the Dashboard uses +to decide whether a person is signed in, and a device is not one. + ## Creating the first platform owner A fresh installation has a login page and nothing to log in to. Public registration is disabled by diff --git a/docs/development/security.md b/docs/development/security.md index ebfd00d..19e3b23 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -544,6 +544,44 @@ is **where it can be invoked**, not a weaker policy: It deliberately does not solve creating *additional* users or owners. That is user administration, which does not exist yet. +### A display device is a principal, not a person and not a shortcut + +An unattended screen has its own identity, paired by an administrator and revocable by one +([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). What makes that +safe is what it is *not* allowed to be: + +- **Not a user.** No device row exists in the identity tables and no device has an email or a + password. A device satisfies the `authenticated` baseline **never**, so every route that settles + for "somebody is signed in" stays closed to a television. +- **Not a bypass.** Device authority is decided by the same `createAuthorizationEvaluator` as + everyone else's, with the same deny-by-default. There is no device branch that returns allowed + before consulting an explicit permission, and no device is ever a platform owner. +- **Not broadly granted.** Approval grants exactly the one display permission the host derived from + the module's own validated display declaration. A browser cannot ask for a permission of its + choosing, and a device paired for one display has no authority over another. +- **Not a Dashboard login.** `/api/v1/principal` reports a device as anonymous, and the shell tests + for `=== "authenticated"` rather than "not anonymous". A device cookie cannot open the Dashboard. +- **Not the pairing code.** The six-digit code is displayed on a wall and is treated as public. It + identifies a request to an administrator; it never yields a credential. Collecting the credential + additionally requires the HttpOnly claim cookie only the requesting browser holds, so reading a + code off a screen is worth nothing. +- **Not stored in a usable form.** The server keeps SHA-256 of the credential. The raw value exists + only in the `Set-Cookie` that issues it and in the device's own cookie jar; it is never logged, put + in a query string, returned in JSON, or written to `localStorage`. The cookie is `HttpOnly`, so the + module display code running in that very page cannot read it. +- **Not revocable only at the browser.** Revocation is a server-side column checked on every + principal resolution. A revoked screen keeps its cookie and stops working; re-pairing creates a + **new** identity, so an old credential can never be reactivated. + +Approval, listing, and revocation require the restricted `core.devices.manage` permission and an +exact trusted `Origin`, exactly like every other Core mutation. A device cannot approve a pairing — +including its own. + +**Bounded, not instantaneous.** A revoked device fails its next request and its next realtime +connection immediately, but an already-open SSE stream is not torn down at that moment: realtime's +five-minute forced reconnect is the upper bound on how long a just-revoked screen can keep receiving +a channel. That window is stated rather than described as instant. + ### Rate limiting is per-process today Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable diff --git a/docs/development/testing.md b/docs/development/testing.md index 2eb5069..ffc0aa3 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -351,6 +351,30 @@ password arriving on standard input and never in `argv`, `--password` refused, a `DATABASE_URL`, and an unmigrated database refused rather than silently migrated — with assertions that neither stdout nor stderr contains the password or the connection string. +A further suite (`apps/api/tests/device-pairing.integration.ts`, same harness) proves display device +pairing end to end against real PostgreSQL and the real HTTP transport +([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). None of it is +mocked: a screen with no identity is refused the descriptor but served the page it needs, asks to be +paired, and receives a code; a real platform owner — created by the Pass 1B bootstrap and signed in +through the real login transport — looks the code up and approves it by name; the screen collects its +credential from a real `Set-Cookie` and then opens the real display descriptor and a real realtime +stream; and the owner revokes it, after which both fail while the device still holds its cookie. + +The suite also pins the properties that make the design safe rather than merely working: that the +raw credential appears nowhere in the database or in any response body, that the permission is taken +from the display declaration and not from a request that tried to name `core.devices.manage`, that +someone who knows only the code (an onlooker reading a television) is issued nothing, that anonymous +callers, a paired device, and a cross-origin administrator are all refused approval, that one code +cannot be approved twice or claimed into two credentials under a race, that re-pairing produces a +fresh identity while the old credential stays dead, and that a browser holding both a human session +and a device cookie remains a person. + +Device authority itself is proven without a database in `core/tests/authorization-device.test.ts`, +which is mostly a list of things a device must *not* acquire: authority from being a principal, from +an `authenticated` baseline, from platform ownership, or from a user grant. Its ports are wired to +answer "yes" to every human question, so any accidental fall-through into a human path would flip +those assertions. + ### Which kind of timing proof lives where Pass 4 introduced several deadlines, and their tests deliberately differ in kind: diff --git a/packages/contracts/src/identity.ts b/packages/contracts/src/identity.ts index c70cddd..cf3b4a8 100644 --- a/packages/contracts/src/identity.ts +++ b/packages/contracts/src/identity.ts @@ -22,6 +22,34 @@ export function safeParseEkklesiaUserId(input: unknown): z.ZodSafeParseResult(); + +export type EkklesiaDeviceId = z.infer; + +export function parseEkklesiaDeviceId(input: unknown): EkklesiaDeviceId { + return ekklesiaDeviceIdSchema.parse(input); +} + +export function safeParseEkklesiaDeviceId(input: unknown): z.ZodSafeParseResult { + return ekklesiaDeviceIdSchema.safeParse(input); +} + export interface EkklesiaAnonymousPrincipal { readonly kind: "anonymous"; } @@ -31,7 +59,20 @@ export interface EkklesiaAuthenticatedPrincipal { readonly userId: EkklesiaUserId; } -export type EkklesiaPrincipal = EkklesiaAnonymousPrincipal | EkklesiaAuthenticatedPrincipal; +export interface EkklesiaDevicePrincipal { + readonly kind: "device"; + readonly deviceId: EkklesiaDeviceId; +} + +/** + * The three principal classes Ekklesia recognizes. They are deliberately a closed union rather than + * "authenticated or not": code that means *a person* must be able to say so, and code that merely + * needs *some* caller must not accidentally accept a device. + */ +export type EkklesiaPrincipal = + | EkklesiaAnonymousPrincipal + | EkklesiaAuthenticatedPrincipal + | EkklesiaDevicePrincipal; export const ANONYMOUS_PRINCIPAL: EkklesiaAnonymousPrincipal = Object.freeze({ kind: "anonymous", @@ -44,14 +85,34 @@ export function createAuthenticatedPrincipal(userId: unknown): EkklesiaAuthentic }); } +export function createDevicePrincipal(deviceId: unknown): EkklesiaDevicePrincipal { + return Object.freeze({ + deviceId: parseEkklesiaDeviceId(deviceId), + kind: "device", + }); +} + export function isAnonymousPrincipal( principal: EkklesiaPrincipal, ): principal is EkklesiaAnonymousPrincipal { return principal.kind === "anonymous"; } +/** + * Whether this principal is a signed-in **person**. + * + * A device principal deliberately returns `false`. Every existing caller of this predicate means + * "a human is authenticated here" — the Dashboard shell, the `authenticated` permission baseline, + * the grants API — and each of them would be wrong about a screen on a wall. + */ export function isAuthenticatedPrincipal( principal: EkklesiaPrincipal, ): principal is EkklesiaAuthenticatedPrincipal { return principal.kind === "authenticated"; } + +export function isDevicePrincipal( + principal: EkklesiaPrincipal, +): principal is EkklesiaDevicePrincipal { + return principal.kind === "device"; +} diff --git a/packages/module-ui-sdk/src/host-context.ts b/packages/module-ui-sdk/src/host-context.ts index c29bba6..7124dc8 100644 --- a/packages/module-ui-sdk/src/host-context.ts +++ b/packages/module-ui-sdk/src/host-context.ts @@ -29,7 +29,26 @@ export interface ModuleUiAuthenticatedPrincipal { readonly userId: string; } -export type ModuleUiPrincipal = ModuleUiAnonymousPrincipal | ModuleUiAuthenticatedPrincipal; +/** + * A managed display device — a screen the platform paired, with no person behind it. + * + * Module UI is told this so it can render honestly: a signage view has nobody to greet, no personal + * data to show, and no interaction to invite. It is deliberately *not* an authenticated principal, + * because module code that asks "is someone signed in?" must not be told yes about a television. + * + * There is nothing here about pairing. How a device came to exist is the host's business; a module + * receives an identity and its permissions, exactly as it does for a person. + */ +export interface ModuleUiDevicePrincipal { + readonly kind: "device"; + /** Opaque to module UI: an identifier to correlate with, never to parse or display verbatim. */ + readonly deviceId: string; +} + +export type ModuleUiPrincipal = + | ModuleUiAnonymousPrincipal + | ModuleUiAuthenticatedPrincipal + | ModuleUiDevicePrincipal; /** * Everything a host hands a module's UI, and the complete list of what module UI may assume exists. diff --git a/surfaces/shell/src/browser/DevicePanel.tsx b/surfaces/shell/src/browser/DevicePanel.tsx new file mode 100644 index 0000000..e5ed427 --- /dev/null +++ b/surfaces/shell/src/browser/DevicePanel.tsx @@ -0,0 +1,207 @@ +import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react"; +import { + approveDevicePairing, + type EkklesiaHostConfig, + type HostDeviceSummary, + listHostDevices, + lookupDevicePairing, + revokeHostDevice, +} from "../host-protocol.js"; + +/** + * The administrator's whole device surface: type the code from a screen, name it, approve it, and + * later take it away again. + * + * Deliberately small. Device identity belongs to the platform rather than to any module, so it has + * to live in the host — but this is infrastructure plumbing, not a fleet-management product, and + * there is no grouping, filtering, bulk action, targeting, health, or remote control here on + * purpose (ADR 0036). It renders only for an administrator who actually holds `core.devices.manage`: + * a viewer without it is told nothing about which devices exist. + */ +const panelStyles = { + action: { + background: "transparent", + border: "1px solid #d6d3d1", + borderRadius: "0.25rem", + cursor: "pointer", + font: "inherit", + padding: "0.25rem 0.5rem", + }, + input: { + border: "1px solid #d6d3d1", + borderRadius: "0.25rem", + font: "inherit", + minWidth: 0, + padding: "0.25rem 0.5rem", + width: "100%", + }, + meta: { color: "#78716c", fontSize: "0.8125rem", margin: 0 }, + row: { + alignItems: "center", + borderTop: "1px solid #e7e5e4", + display: "flex", + gap: "0.5rem", + justifyContent: "space-between", + padding: "0.375rem 0", + }, + section: { display: "grid", gap: "0.5rem" }, + title: { fontSize: "0.8125rem", fontWeight: 600, letterSpacing: "0.04em", margin: 0 }, +} as const; + +type PairingStep = + | { readonly step: "idle" } + | { readonly step: "busy" } + | { readonly step: "found"; readonly moduleId: string; readonly permissionId: string } + | { readonly step: "error"; readonly message: string }; + +export interface DevicePanelProps { + readonly config: EkklesiaHostConfig; +} + +export function DevicePanel(props: DevicePanelProps): ReactElement | undefined { + const { config } = props; + const [devices, setDevices] = useState(undefined); + const [permitted, setPermitted] = useState(true); + const [code, setCode] = useState(""); + const [name, setName] = useState(""); + const [pairing, setPairing] = useState({ step: "idle" }); + + const refresh = useCallback(async (): Promise => { + try { + setDevices(await listHostDevices(config)); + } catch { + // The panel simply does not render for someone who may not manage devices; it never explains + // what it would have shown. + setPermitted(false); + } + }, [config]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + if (!permitted) { + return undefined; + } + + const lookUp = async (event: FormEvent): Promise => { + event.preventDefault(); + setPairing({ step: "busy" }); + try { + const found = await lookupDevicePairing(config, code.trim()); + setPairing({ moduleId: found.moduleId, permissionId: found.permissionId, step: "found" }); + } catch { + // One message for expired, consumed, mistyped, and never-existed alike. + setPairing({ message: "That code is not waiting for approval.", step: "error" }); + } + }; + + const approve = async (): Promise => { + setPairing({ step: "busy" }); + try { + await approveDevicePairing(config, code.trim(), name.trim()); + setCode(""); + setName(""); + setPairing({ step: "idle" }); + await refresh(); + } catch { + setPairing({ message: "That code could not be approved.", step: "error" }); + } + }; + + const revoke = async (deviceId: string): Promise => { + try { + await revokeHostDevice(config, deviceId); + await refresh(); + } catch { + // Left to the next refresh rather than asserting a failure the operator cannot act on. + } + await refresh(); + }; + + return ( +
+

DISPLAY DEVICES

+ +
void lookUp(event)} style={{ display: "grid", gap: "0.375rem" }}> + + setCode(event.target.value)} + style={panelStyles.input} + value={code} + /> + +
+ + {pairing.step === "found" ? ( +
+

+ A display is asking to show {pairing.moduleId}. +

+ + setName(event.target.value)} + placeholder="Foyer TV" + style={panelStyles.input} + value={name} + /> + +
+ ) : undefined} + + {pairing.step === "error" ? ( +

+ {pairing.message} +

+ ) : undefined} + + {devices !== undefined && devices.length === 0 ? ( +

+ No displays are paired. +

+ ) : undefined} + + {devices?.map((device) => ( +
+ + {device.name} + {device.revoked ? " (revoked)" : ""} + + {device.revoked ? undefined : ( + + )} +
+ ))} +
+ ); +} diff --git a/surfaces/shell/src/browser/HostApp.tsx b/surfaces/shell/src/browser/HostApp.tsx index daf6709..5230333 100644 --- a/surfaces/shell/src/browser/HostApp.tsx +++ b/surfaces/shell/src/browser/HostApp.tsx @@ -14,6 +14,7 @@ import { HostProtocolError, loadHostPrincipal, } from "../host-protocol.js"; +import { DevicePanel } from "./DevicePanel.js"; import { LoginForm } from "./LoginForm.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js"; @@ -157,9 +158,13 @@ async function resolveBootstrapState( ): Promise { try { const bootstrap = await bootstrapHost(config, signal); - return bootstrap.principal.kind === "anonymous" - ? { status: "anonymous" } - : { bootstrap, status: "ready" }; + // Only a person signs into the Dashboard. The test is `=== "authenticated"` and never "not + // anonymous", so a principal class added later cannot quietly become a Dashboard login — which + // is precisely how a paired display device would otherwise have walked in (ADR 0036). In + // practice `/api/v1/principal` never reports a device at all; this is the second lock. + return bootstrap.principal.kind === "authenticated" + ? { bootstrap: { ...bootstrap, principal: bootstrap.principal }, status: "ready" } + : { status: "anonymous" }; } catch (error) { if (error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED") { return { status: "anonymous" }; @@ -167,9 +172,9 @@ async function resolveBootstrapState( if (error instanceof HostProtocolError && error.code === "HOST_ACCESS_DENIED") { try { const principal = await loadHostPrincipal(config, signal); - return principal.kind === "anonymous" - ? { status: "anonymous" } - : { principal, status: "access-denied" }; + return principal.kind === "authenticated" + ? { principal, status: "access-denied" } + : { status: "anonymous" }; } catch { return { status: "failed" }; } @@ -454,6 +459,7 @@ export function HostApp(props: HostAppProps): ReactElement {