feat: give display devices their own paired identity
CI / verify (pull_request) Successful in 40s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m22s

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 <[email protected]>
This commit is contained in:
ekklesia
2026-08-23 23:41:03 -05:00
co-authored by Claude Opus 5
parent 3ce4fe03fe
commit 80189c8ae2
52 changed files with 5065 additions and 113 deletions
+35 -2
View File
@@ -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/<module-id>/<version>/display`, serves a stable permission-gated page at
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
+30
View File
@@ -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`.
+3
View File
@@ -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),
+94
View File
@@ -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<Response> =>
options.runtime.handleAuthRequest(request),
async resolvePrincipal(headers: Headers): Promise<EkklesiaPrincipal> {
// 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);
},
});
}
+357
View File
@@ -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<string>;
/** True when the platform is reached over https, which is what `Secure` requires to be usable at
* all. Derived from the validated identity base URL, never from a request. */
readonly secureCookies: boolean;
readonly now?: () => Date;
}
function noStore(c: Context): void {
c.header("Cache-Control", "no-store");
}
function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response {
noStore(c);
return c.json({ code }, status);
}
async function readJsonObject(c: Context): Promise<Record<string, unknown> | undefined> {
try {
const body: unknown = await c.req.json();
return typeof body === "object" && body !== null && !Array.isArray(body)
? (body as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
function readString(body: Record<string, unknown>, key: string, max: number): string | undefined {
const value = body[key];
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length === 0 || trimmed.length > max ? undefined : trimmed;
}
/**
* Writes one of the device cookies.
*
* `HttpOnly` so no script — least of all a module's display bundle, which runs in this very page —
* can read the value; `SameSite=Strict` because a display only ever talks to the host that serves
* it and nothing legitimate arrives cross-site; host-only (no `Domain`) so a sibling hostname
* cannot claim it; `Path=/` because the credential is presented to the descriptor, the module API,
* and realtime alike. `Secure` follows the deployment's own scheme — asserting it on a plaintext
* development origin would make the cookie silently unusable rather than more secure.
*/
function writeDeviceCookie(
c: Context,
options: 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;
}
+49 -2
View File
@@ -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<HostModuleDisplayResource> {
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);
}
+19 -2
View File
@@ -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<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
let deviceStore: ReturnType<typeof createDeviceStore>;
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) {
+4 -3
View File
@@ -223,6 +223,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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
@@ -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<string>();
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<Client> {
const client = new Client({ connectionString: baseDatabaseUrl() });
await client.connect();
return client;
}
async function safeQuery<T extends QueryResultRow>(
client: Client,
text: string,
values: readonly unknown[] = [],
): Promise<T[]> {
try {
return (await client.query<T>(text, [...values])).rows;
} catch {
throw new IntegrationHarnessError("An integration database statement failed.");
}
}
async function query<T extends QueryResultRow>(
url: string,
text: string,
values: readonly unknown[] = [],
): Promise<T[]> {
const client = new Client({ connectionString: url });
await client.connect();
try {
return await safeQuery<T>(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<string> {
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<string> {
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<RunningApi> {
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<string> {
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<void>;
}
async function platform(label: string): Promise<Platform> {
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<string, string>();
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<Response> {
const cookie = this.header();
const response = await fetch(`${this.origin}${path}`, {
...init,
headers: {
origin: dashboardOrigin,
...(cookie.length > 0 ? { cookie } : {}),
...(init.headers as Record<string, string> | undefined),
},
});
this.absorb(response);
return response;
}
post(path: string, body: unknown): Promise<Response> {
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<Response> {
return fetch(`${origin}${path}`, {
...(body === undefined ? {} : { body: JSON.stringify(body) }),
headers: {
cookie,
origin: dashboardOrigin,
...(body === undefined ? {} : { "content-type": "application/json" }),
...(overrides.headers as Record<string, string> | 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<string, unknown>;
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();
}
});
});
@@ -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<void> {
"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<void> {
"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(
+10 -5
View File
@@ -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: {
+1
View File
@@ -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: {
+53
View File
@@ -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");
File diff suppressed because it is too large Load Diff
+6
View File
@@ -37,6 +37,12 @@
"destructive": {
"mode": "none"
}
},
{
"tag": "0006_device_identity",
"destructive": {
"mode": "none"
}
}
]
}
+7
View File
@@ -43,6 +43,13 @@
"when": 1787451496425,
"tag": "0005_module_configuration",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1787543773057,
"tag": "0006_device_identity",
"breakpoints": true
}
]
}
+4
View File
@@ -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": {
+41 -7
View File
@@ -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;
+22
View File
@@ -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,
}),
]);
/**
+20 -1
View File
@@ -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<boolean>;
}
/**
* 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<boolean>;
}
+16
View File
@@ -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<typeof deviceSchema>;
/**
* 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 });
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./database.js";
export * from "./schema.js";
export * from "./store.js";
+168
View File
@@ -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,
};
+552
View File
@@ -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<Record<DeviceStoreErrorCode, string>> = 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<CreatedPairingRequest>;
/** Looks a live request up by the code an administrator typed. Never used by the device itself. */
findPendingByCode(code: string, now: Date): Promise<PairingRequestView | undefined>;
approvePairingRequest(input: {
readonly code: string;
readonly deviceName: string;
readonly approvedBy: EkklesiaUserId;
readonly now: Date;
}): Promise<PairingRequestView | undefined>;
/** 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<ResolvedDevice | undefined>;
touchLastSeen(deviceId: EkklesiaDeviceId, now: Date): Promise<void>;
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
listDevices(): Promise<readonly DeviceSummary[]>;
revokeDevice(input: {
readonly deviceId: EkklesiaDeviceId;
readonly revokedBy: EkklesiaUserId;
readonly now: Date;
}): Promise<void>;
}
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<void> => {
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<PairingRequestView | undefined> {
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<CreatedPairingRequest> {
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<PairingRequestView | undefined> {
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<boolean> {
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<readonly DeviceSummary[]> {
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<ResolvedDevice | undefined> {
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<void> {
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<void> {
// 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 };
+189
View File
@@ -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" });
});
});
+8 -1
View File
@@ -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) {
@@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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",
@@ -175,6 +175,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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 {
+1 -1
View File
@@ -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
@@ -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<void> {
"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 {
@@ -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<void> {
"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 {
+3 -1
View File
@@ -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",
@@ -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.
+1
View File
@@ -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
@@ -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
+37
View File
@@ -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/<module-id>` route is.
## Module HTTP routes
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
+24
View File
@@ -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
+14
View File
@@ -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
+38
View File
@@ -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
+24
View File
@@ -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:
+62 -1
View File
@@ -22,6 +22,34 @@ export function safeParseEkklesiaUserId(input: unknown): z.ZodSafeParseResult<Ek
return ekklesiaUserIdSchema.safeParse(input);
}
/**
* A managed display device: a TV, signage player, or confidence monitor that the platform paired
* and can revoke.
*
* It is deliberately **not** a user. A device has no email, no password, no session that a person
* signs into, and no row in the user table — representing one as a fake user would make every
* "is this a person?" question in the platform unanswerable, and would quietly hand a screen in a
* hallway whatever a signed-in human is allowed to do.
*
* A device principal carries only the permissions explicitly granted to that device. It satisfies
* no `authenticated` baseline: "unattended screen in the foyer" is not "a person is signed in",
* and every route gated only by "someone is logged in" would otherwise open to it (ADR 0036).
*/
export const ekklesiaDeviceIdSchema = z
.string()
.regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u)
.brand<"EkklesiaDeviceId">();
export type EkklesiaDeviceId = z.infer<typeof ekklesiaDeviceIdSchema>;
export function parseEkklesiaDeviceId(input: unknown): EkklesiaDeviceId {
return ekklesiaDeviceIdSchema.parse(input);
}
export function safeParseEkklesiaDeviceId(input: unknown): z.ZodSafeParseResult<EkklesiaDeviceId> {
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";
}
+20 -1
View File
@@ -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.
+207
View File
@@ -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<readonly HostDeviceSummary[] | undefined>(undefined);
const [permitted, setPermitted] = useState(true);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [pairing, setPairing] = useState<PairingStep>({ step: "idle" });
const refresh = useCallback(async (): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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 (
<section data-testid="host-devices" style={panelStyles.section}>
<h2 style={panelStyles.title}>DISPLAY DEVICES</h2>
<form onSubmit={(event) => void lookUp(event)} style={{ display: "grid", gap: "0.375rem" }}>
<label htmlFor="device-pairing-code" style={panelStyles.meta}>
Pairing code shown on the screen
</label>
<input
autoComplete="off"
data-testid="device-pairing-code-input"
id="device-pairing-code"
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value)}
style={panelStyles.input}
value={code}
/>
<button data-testid="device-pairing-lookup" style={panelStyles.action} type="submit">
Find display
</button>
</form>
{pairing.step === "found" ? (
<div data-testid="device-pairing-found" style={{ display: "grid", gap: "0.375rem" }}>
<p style={panelStyles.meta}>
A display is asking to show <strong>{pairing.moduleId}</strong>.
</p>
<label htmlFor="device-pairing-name" style={panelStyles.meta}>
Name this display
</label>
<input
autoComplete="off"
data-testid="device-pairing-name"
id="device-pairing-name"
maxLength={128}
onChange={(event) => setName(event.target.value)}
placeholder="Foyer TV"
style={panelStyles.input}
value={name}
/>
<button
data-testid="device-pairing-approve"
disabled={name.trim().length === 0}
onClick={() => void approve()}
style={panelStyles.action}
type="button"
>
Approve
</button>
</div>
) : undefined}
{pairing.step === "error" ? (
<p data-testid="device-pairing-error" style={panelStyles.meta}>
{pairing.message}
</p>
) : undefined}
{devices !== undefined && devices.length === 0 ? (
<p data-testid="device-list-empty" style={panelStyles.meta}>
No displays are paired.
</p>
) : undefined}
{devices?.map((device) => (
<div data-testid={`device-${device.id}`} key={device.id} style={panelStyles.row}>
<span>
{device.name}
{device.revoked ? " (revoked)" : ""}
</span>
{device.revoked ? undefined : (
<button
data-testid={`device-revoke-${device.id}`}
onClick={() => void revoke(device.id)}
style={panelStyles.action}
type="button"
>
Revoke
</button>
)}
</div>
))}
</section>
);
}
+12 -6
View File
@@ -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<ResolvedBootstrapState> {
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 {
</button>
</header>
<aside style={shellStyles.aside}>
<DevicePanel config={config} />
{entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: "#57534e", margin: 0 }}>
No installed module provides a user interface.
+16 -14
View File
@@ -7,11 +7,13 @@ import {
HostProtocolError,
} from "../host-protocol.js";
import { DisplayUiFrame } from "./DisplayUiFrame.js";
import { PairingScreen } from "./PairingScreen.js";
type DisplayState =
| { readonly status: "loading"; readonly generation: number }
| { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } }
| { readonly status: "signed-out" }
/** No identity this display can use yet: show a pairing code rather than a human login form. */
| { readonly status: "pairing" }
| { readonly status: "unavailable" }
| { readonly status: "failed" };
@@ -93,23 +95,25 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
if (abort.signal.aborted) {
return;
}
setState(
bootstrap.status === "signed-out"
? { status: "signed-out" }
: { bootstrap, status: "ready" },
);
setState({ bootstrap, status: "ready" });
})
.catch((error: unknown) => {
if (abort.signal.aborted) {
return;
}
// A denied or unauthenticated descriptor is the ordinary state of a screen nobody has
// paired yet, not an error worth logging on every wall in the building.
if (
error instanceof HostProtocolError &&
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
) {
setState({ status: "pairing" });
return;
}
console.error("[ekklesia] display host bootstrap failed", error);
setState(
error instanceof HostProtocolError &&
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "DISPLAY_NOT_AVAILABLE")
error instanceof HostProtocolError && error.code === "DISPLAY_NOT_AVAILABLE"
? { status: "unavailable" }
: error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED"
? { status: "signed-out" }
: { status: "failed" },
);
});
@@ -123,10 +127,8 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
{state.status === "loading" ? (
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
) : undefined}
{state.status === "signed-out" ? (
<DisplayMessage onRetry={retry} testId="display-host-signed-out">
Sign in to use this display.
</DisplayMessage>
{state.status === "pairing" ? (
<PairingScreen config={config} moduleId={moduleId} onRetry={retry} />
) : undefined}
{state.status === "unavailable" ? (
<DisplayMessage onRetry={retry} testId="display-host-unavailable">
@@ -0,0 +1,154 @@
import { type ReactElement, useEffect, useRef, useState } from "react";
import {
type EkklesiaHostConfig,
HostProtocolError,
pollDevicePairing,
requestDevicePairing,
} from "../host-protocol.js";
/**
* What an unpaired screen shows: a short code, and nothing else of consequence.
*
* This page is looked at by whoever walks past a television, so it deliberately carries no request
* id, no claim secret, no permission id, no module internals, and no server URL beyond the one the
* browser is already on. The code is the only thing on it that means anything, and the code is
* useless on its own — approving it requires an administrator, and collecting the credential
* requires the HttpOnly cookie this browser holds and nobody watching can see (ADR 0036).
*/
const pairingStyles = {
code: {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "clamp(3rem, 14vw, 9rem)",
fontWeight: 600,
letterSpacing: "0.15em",
lineHeight: 1.1,
margin: 0,
},
hint: { color: "#78716c", margin: 0, maxWidth: "40ch" },
root: {
alignItems: "center",
background: "#0c0a09",
color: "#fafaf9",
display: "flex",
flexDirection: "column",
gap: "1.5rem",
height: "100%",
justifyContent: "center",
minHeight: "100dvh",
padding: "2rem",
textAlign: "center",
},
title: { fontFamily: "system-ui, sans-serif", fontSize: "1.25rem", fontWeight: 500, margin: 0 },
} as const;
type PairingState =
| { readonly status: "requesting" }
| { readonly status: "waiting"; readonly code: string }
| { readonly status: "unavailable" };
export interface PairingScreenProps {
readonly config: EkklesiaHostConfig;
readonly moduleId: string;
/** Called once the host has issued this device its session, so the display mounts itself without
* anyone touching the television. */
readonly onRetry: () => void;
}
export function PairingScreen(props: PairingScreenProps): ReactElement {
const { config, moduleId, onRetry } = props;
const [state, setState] = useState<PairingState>({ status: "requesting" });
const pairedRef = useRef(onRetry);
pairedRef.current = onRetry;
useEffect(() => {
const abort = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
const stop = (): void => {
stopped = true;
if (timer !== undefined) {
clearTimeout(timer);
}
};
const begin = async (): Promise<void> => {
let requested: { code: string; pollIntervalMs: number };
try {
requested = await requestDevicePairing(config, moduleId, abort.signal);
} catch (error) {
if (!abort.signal.aborted) {
// Never surfaces the underlying reason on a public screen.
if (!(error instanceof HostProtocolError)) {
console.error("[ekklesia] pairing request failed");
}
setState({ status: "unavailable" });
}
return;
}
if (stopped || abort.signal.aborted) {
return;
}
setState({ code: requested.code, status: "waiting" });
const poll = async (): Promise<void> => {
if (stopped || abort.signal.aborted) {
return;
}
let status: string;
try {
status = await pollDevicePairing(config, abort.signal);
} catch {
// A transient failure is not a reason to abandon a screen that may be on a wall; it asks
// again on the next tick.
timer = setTimeout(() => void poll(), requested.pollIntervalMs);
return;
}
if (status === "paired") {
stop();
pairedRef.current();
return;
}
if (status === "expired" || status === "unknown") {
// The code aged out before anyone approved it. Draw a fresh one rather than leaving a
// dead number on the screen.
stop();
void begin();
return;
}
timer = setTimeout(() => void poll(), requested.pollIntervalMs);
};
timer = setTimeout(() => void poll(), requested.pollIntervalMs);
};
void begin();
return () => {
stop();
abort.abort();
};
}, [config, moduleId]);
return (
<div data-testid="display-pairing" style={pairingStyles.root}>
{state.status === "waiting" ? (
<>
<p style={pairingStyles.title}>Pair this display</p>
<p data-testid="display-pairing-code" style={pairingStyles.code}>
{state.code}
</p>
<p style={pairingStyles.hint}>
Enter this code in the Ekklesia dashboard to connect this screen. Waiting for approval…
</p>
</>
) : undefined}
{state.status === "requesting" ? (
<p style={pairingStyles.title}>Preparing this display…</p>
) : undefined}
{state.status === "unavailable" ? (
<p data-testid="display-pairing-unavailable" style={pairingStyles.title}>
This display is not available.
</p>
) : undefined}
</div>
);
}
+245 -20
View File
@@ -231,13 +231,37 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
*/
export interface HostModuleDisplayResponse {
readonly module: HostModuleUiDescriptor;
/** Who the host resolved as the viewer. Read from the response that already authorized this
* display rather than from `/api/v1/principal`, which answers only about people. */
readonly viewer: ModuleUiPrincipal;
}
/** Strictly validates the host's statement of who is viewing. Only the two principal kinds a
* `restricted` display permission can admit are accepted — an anonymous viewer here would mean the
* server authorized a display for nobody, which is a protocol violation rather than a state to
* render. */
function parseDisplayViewer(payload: unknown): ModuleUiPrincipal {
if (hasExactKeys(payload, ["kind", "userId"]) && payload.kind === "authenticated") {
const userId = nonEmptyString(payload.userId);
if (userId !== undefined) {
return Object.freeze({ kind: "authenticated" as const, userId });
}
}
if (hasExactKeys(payload, ["deviceId", "kind"]) && payload.kind === "device") {
const deviceId = nonEmptyString(payload.deviceId);
if (deviceId !== undefined) {
return Object.freeze({ deviceId, kind: "device" as const });
}
}
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
/** Strictly validates the display descriptor response, including every nested URL container. */
export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayResponse {
if (!hasExactKeys(payload, ["module"])) {
if (!hasExactKeys(payload, ["module", "viewer"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const viewer = parseDisplayViewer(payload.viewer);
const entry = payload.module;
if (
!hasExactKeys(entry, ["api", "display", "id", "permissions", "realtime", "version"]) ||
@@ -278,9 +302,207 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
uiUrl,
version,
}),
viewer,
});
}
/** A same-origin, credential-carrying platform mutation. Mirrors `auth-protocol.ts`'s `postJson`:
* `redirect: "error"` so a misconfigured deployment cannot silently follow a redirect with the
* device's cookies attached, and no thrown value ever carries the request body. */
async function requestPlatformMutation(
config: EkklesiaHostConfig,
rootRelativePath: string,
body: unknown,
signal?: AbortSignal,
): Promise<unknown> {
const performRequest = config.fetch ?? fetch;
let response: Response;
try {
response = await performRequest(resolveApiUrl(config, rootRelativePath), {
body: JSON.stringify(body),
cache: "no-store",
credentials: "include",
headers: { accept: "application/json", "content-type": "application/json" },
method: "POST",
redirect: "error",
...(signal ? { signal } : {}),
});
} catch (error) {
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
}
if (response.status === 401 || response.status === 403) {
throw new HostProtocolError("HOST_UNAUTHENTICATED");
}
if (!response.ok) {
throw new HostProtocolError("HOST_REQUEST_FAILED");
}
try {
return (await response.json()) as unknown;
} catch {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
}
/**
* Asks the host to start pairing this screen, and returns the code to put on it.
*
* The response deliberately carries no secret. The value that proves this browser is the one that
* asked travels in an HttpOnly cookie the host sets, so nothing here — and nothing anyone reads off
* the screen — can be used to collect the device's credential (ADR 0036).
*/
export async function requestDevicePairing(
config: EkklesiaHostConfig,
moduleId: string,
signal?: AbortSignal,
): Promise<{ readonly code: string; readonly pollIntervalMs: number }> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/requests",
{ moduleId },
signal,
);
if (!hasExactKeys(payload, ["code", "expiresAt", "pollIntervalMs"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const code = nonEmptyString(payload.code);
const pollIntervalMs = payload.pollIntervalMs;
if (
code === undefined ||
typeof pollIntervalMs !== "number" ||
!Number.isFinite(pollIntervalMs)
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ code, pollIntervalMs: Math.max(1000, Math.trunc(pollIntervalMs)) });
}
/** Asks whether this screen has been approved yet. When it has, the host issues the device session
* in a `Set-Cookie` the browser stores and this code never sees. */
export async function pollDevicePairing(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<string> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/claims",
{},
signal,
);
if (!hasExactKeys(payload, ["status"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const status = nonEmptyString(payload.status);
if (status === undefined) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return status;
}
/** One paired display, as the administrator's panel shows it. Deliberately no credential, no hash,
* and no pairing history — none of that is anything an operator can act on. */
export interface HostDeviceSummary {
readonly id: string;
readonly name: string;
readonly permissions: readonly string[];
readonly revoked: boolean;
}
export async function lookupDevicePairing(
config: EkklesiaHostConfig,
code: string,
signal?: AbortSignal,
): Promise<{ readonly moduleId: string; readonly permissionId: string }> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/lookups",
{ code },
signal,
);
if (!hasExactKeys(payload, ["expiresAt", "moduleId", "permissionId", "requestedAt"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const moduleId = nonEmptyString(payload.moduleId);
const permissionId = nonEmptyString(payload.permissionId);
if (moduleId === undefined || permissionId === undefined) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ moduleId, permissionId });
}
export async function approveDevicePairing(
config: EkklesiaHostConfig,
code: string,
deviceName: string,
signal?: AbortSignal,
): Promise<void> {
await requestPlatformMutation(
config,
"/api/v1/device-pairing/approvals",
{ code, deviceName },
signal,
);
}
export async function listHostDevices(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<readonly HostDeviceSummary[]> {
const payload = await requestPlatformJson(config, "/api/v1/devices", signal, {
forbidden: "HOST_ACCESS_DENIED",
});
if (typeof payload !== "object" || payload === null || !("devices" in payload)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const entries = (payload as { devices: unknown }).devices;
if (!Array.isArray(entries)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze(
entries.map((entry) => {
const id = nonEmptyString((entry as { id?: unknown }).id);
const name = nonEmptyString((entry as { name?: unknown }).name);
const permissions = (entry as { permissions?: unknown }).permissions;
if (id === undefined || name === undefined || !Array.isArray(permissions)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
id,
name,
permissions: Object.freeze(permissions.map((permission) => String(permission))),
revoked: (entry as { revoked?: unknown }).revoked === true,
});
}),
);
}
export async function revokeHostDevice(
config: EkklesiaHostConfig,
deviceId: string,
signal?: AbortSignal,
): Promise<void> {
const performRequest = config.fetch ?? fetch;
let response: Response;
try {
response = await performRequest(
resolveApiUrl(config, `/api/v1/devices/${encodeURIComponent(deviceId)}/revoke`),
{
body: "{}",
cache: "no-store",
credentials: "include",
headers: { accept: "application/json", "content-type": "application/json" },
method: "POST",
redirect: "error",
...(signal ? { signal } : {}),
},
);
} catch (error) {
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
}
if (!response.ok) {
throw new HostProtocolError("HOST_REQUEST_FAILED");
}
}
/** Reads one exact `/display/<module-id>` pathname without accepting a trailing path or traversal. */
export function parseDisplayRoute(pathname: string): string {
const match = /^\/display\/([^/]+)$/u.exec(pathname);
@@ -480,43 +702,46 @@ export async function loadModuleDisplay(
config: EkklesiaHostConfig,
moduleId: string,
signal?: AbortSignal,
): Promise<HostModuleUiDescriptor> {
): Promise<HostModuleDisplayResponse> {
const payload = await requestPlatformJson(config, moduleDisplayDescriptorPath(moduleId), signal, {
forbidden: "DISPLAY_ACCESS_DENIED",
notFound: "DISPLAY_NOT_AVAILABLE",
});
const descriptor = parseHostModuleDisplay(payload).module;
if (descriptor.id !== moduleId) {
const response = parseHostModuleDisplay(payload);
if (response.module.id !== moduleId) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return descriptor;
return response;
}
export type DisplayHostBootstrap =
| {
readonly status: "signed-out";
readonly principal: Extract<ModuleUiPrincipal, { readonly kind: "anonymous" }>;
}
| {
export type DisplayHostBootstrap = {
readonly status: "ready";
readonly descriptor: HostModuleUiDescriptor;
readonly principal: Extract<ModuleUiPrincipal, { readonly kind: "authenticated" }>;
readonly principal: ModuleUiPrincipal;
};
/** Principal first, then protected display metadata; anonymous browsers never probe a module id. */
/**
* Descriptor first, deliberately — the opposite of the Dashboard's principal-first bootstrap.
*
* The Dashboard asks who you are before asking what you may mount, because it is asking about a
* person and `/api/v1/principal` answers that plainly. A display cannot: a paired device is
* anonymous to that endpoint by design (ADR 0036), so a principal-first display would conclude that
* every device in the building was signed out. The descriptor is the actual authorization boundary
* for a display, and it reports the viewer it authorized, so one request settles both questions for
* a person and a device alike.
*
* A denied descriptor throws `DISPLAY_ACCESS_DENIED`, which is what the host turns into pairing.
*/
export async function bootstrapDisplayHost(
config: EkklesiaHostConfig,
moduleId: string,
signal?: AbortSignal,
): Promise<DisplayHostBootstrap> {
const principal = await loadHostPrincipal(config, signal);
if (principal.kind === "anonymous") {
return Object.freeze({ principal, status: "signed-out" });
}
const response = await loadModuleDisplay(config, moduleId, signal);
return Object.freeze({
descriptor: await loadModuleDisplay(config, moduleId, signal),
principal,
status: "ready",
descriptor: response.module,
principal: response.viewer,
status: "ready" as const,
});
}
+46 -11
View File
@@ -35,8 +35,16 @@ afterEach(async () => {
consoleError.mockRestore();
});
function descriptorPayload(id = "announcements", version = "1.2.0") {
function descriptorPayload(
id = "announcements",
version = "1.2.0",
viewer: unknown = {
kind: "authenticated",
userId: "user-1",
},
) {
return {
viewer,
module: {
api: { baseUrl: `/api/v1/modules/${id}` },
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
@@ -71,6 +79,15 @@ function platform(options: PlatformOptions = {}) {
if (url.endsWith("/api/v1/module-displays/announcements")) {
return json(descriptorPayload(), options.descriptorStatus ?? 200);
}
if (url.endsWith("/api/v1/device-pairing/requests")) {
return json(
{ code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 },
201,
);
}
if (url.endsWith("/api/v1/device-pairing/claims")) {
return json({ status: "pending" });
}
if (url.endsWith("/api/v1/modules/announcements/slides")) {
return json([{ id: "slide-1" }]);
}
@@ -394,26 +411,44 @@ describe("display host — full-viewport boundary", () => {
});
describe("display host — authentication and controlled failures", () => {
it("mounts nothing and never requests a descriptor for an anonymous browser", async () => {
const server = platform({ principal: { kind: "anonymous" } });
it("shows a pairing code instead of a human login when it has no identity", async () => {
const server = platform({ descriptorStatus: 403 });
const importer = vi.fn(async () => artifact());
await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(query("display-host-signed-out")).not.toBeNull();
// An unattended screen must never be sent to an email/password form: nobody is standing at it.
expect(query("display-host-signed-out")).toBeNull();
expect(query("display-pairing")).not.toBeNull();
expect(query("display-pairing-code")?.textContent).toBe("482731");
// The protected artifact is still not fetched, which is the invariant that matters.
expect(importer).not.toHaveBeenCalled();
expect(server.calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
// And the display never asks the human principal endpoint at all.
expect(server.calls.some((call) => call.url.endsWith("/api/v1/principal"))).toBe(false);
});
it("does not import protected display code when the descriptor is denied or unavailable", async () => {
for (const status of [403, 404]) {
const server = platform({ descriptorStatus: status });
it("puts no request id, secret, or permission on a screen anyone can read", async () => {
const server = platform({ descriptorStatus: 403 });
await render(
{ apiBaseUrl, fetch: server.fetch },
vi.fn(async () => artifact()),
);
const rendered = container.textContent ?? "";
expect(rendered).toContain("482731");
expect(rendered).not.toMatch(/announcements\.slideshow\.view|device-pairing|claim|token/u);
});
it("does not import protected display code when the display is unavailable", async () => {
const server = platform({ descriptorStatus: 404 });
const importer = vi.fn(async () => artifact());
await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(query("display-host-unavailable"), String(status)).not.toBeNull();
expect(importer, String(status)).not.toHaveBeenCalled();
}
// 404 is "there is no such display", which pairing cannot fix — so it stays a plain message.
expect(query("display-host-unavailable")).not.toBeNull();
expect(query("display-pairing")).toBeNull();
expect(importer).not.toHaveBeenCalled();
});
it("clears partial mount DOM, renders no caught detail, and retries in a fresh container", async () => {
@@ -11,8 +11,12 @@ const apiBaseUrl = "https://ekklesia.example.test";
function displayPayload(overrides: Record<string, unknown> = {}): {
readonly module: Record<string, unknown>;
readonly viewer: Record<string, unknown>;
} {
return {
// The host states who it authorized, so the display never has to infer it from the absence of a
// human session (ADR 0036).
viewer: { kind: "authenticated", userId: "user-1" },
module: {
api: { baseUrl: "/api/v1/modules/announcements" },
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
@@ -45,6 +49,7 @@ describe("display host protocol — strict descriptor", () => {
uiUrl: "/api/v1/module-ui/announcements/1.2.0/display",
version: "1.2.0",
},
viewer: { kind: "authenticated", userId: "user-1" },
});
expect(Object.isFrozen(parsed)).toBe(true);
expect(Object.isFrozen(parsed.module)).toBe(true);
@@ -124,35 +129,58 @@ describe("display host protocol — outer route", () => {
});
describe("display host protocol — authenticated bootstrap", () => {
it("loads principal first, then the exact descriptor, with host-owned credentials", async () => {
it("asks only the descriptor, which is the display's actual authorization boundary", async () => {
const calls: Array<{ init: RequestInit | undefined; url: string }> = [];
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ init, url });
return url.endsWith("/api/v1/principal")
? json({ kind: "authenticated", userId: "user-1" })
: json(displayPayload());
return json(displayPayload());
}) as unknown as typeof globalThis.fetch;
const bootstrap = await bootstrapDisplayHost({ apiBaseUrl, fetch }, "announcements");
expect(bootstrap.status).toBe("ready");
// Deliberately **not** principal-first, unlike the Dashboard: `/api/v1/principal` answers about
// people, and a paired device is anonymous to it by design, so asking it first would conclude
// that every display in the building was signed out (ADR 0036).
expect(calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/principal`,
`${apiBaseUrl}/api/v1/module-displays/announcements`,
]);
expect(calls.every((call) => call.init?.credentials === "include")).toBe(true);
});
it("does not probe a display descriptor for an anonymous principal", async () => {
it("reports the viewer the host authorized, for a person and for a device alike", async () => {
const forViewer = async (viewer: Record<string, unknown>) => {
const fetch = vi.fn(async () =>
json({ kind: "anonymous" }),
json({ ...displayPayload(), viewer }),
) as unknown as typeof globalThis.fetch;
return bootstrapDisplayHost({ apiBaseUrl, fetch }, "announcements");
};
const bootstrap = await bootstrapDisplayHost({ apiBaseUrl, fetch }, "announcements");
expect((await forViewer({ kind: "authenticated", userId: "user-1" })).principal).toEqual({
kind: "authenticated",
userId: "user-1",
});
expect(
(await forViewer({ deviceId: "3f1c8a4e-1d2b-4c3a-9e5f-6a7b8c9d0e1f", kind: "device" }))
.principal,
).toEqual({ deviceId: "3f1c8a4e-1d2b-4c3a-9e5f-6a7b8c9d0e1f", kind: "device" });
});
expect(bootstrap).toEqual({ principal: { kind: "anonymous" }, status: "signed-out" });
expect(fetch).toHaveBeenCalledTimes(1);
it("refuses a descriptor that claims an anonymous or malformed viewer", async () => {
// A `restricted` display permission admits no anonymous principal, so a server saying it
// authorized one is a protocol violation rather than a state to render.
for (const viewer of [
{ kind: "anonymous" },
{ kind: "device" },
{ kind: "authenticated" },
{ deviceId: "", kind: "device" },
{ kind: "device", userId: "user-1" },
]) {
expect(() => parseHostModuleDisplay({ ...displayPayload(), viewer })).toThrowError(
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
);
}
});
it("distinguishes denied, unavailable, unauthenticated, and infrastructure failures safely", async () => {
+2
View File
@@ -443,6 +443,8 @@ describe("Ekklesia shell — browser authentication", () => {
"/api/auth/sign-in/email",
"/api/v1/principal",
"/api/v1/dashboard/navigation",
// The host-owned device panel loads with the rest of the shell for an administrator.
"/api/v1/devices",
]);
});
+10 -1
View File
@@ -215,8 +215,17 @@ describe("the display host is a separate generic browser bundle", () => {
});
it("knows only its host route resources, with no Dashboard application or transport coupling", () => {
expect(bundle).toContain('"/api/v1/principal"');
expect(bundle).toContain('"/api/v1/module-displays"');
// Pairing is a display concern and lives here; the Dashboard bundle has its own management UI.
expect(bundle).toContain('"/api/v1/device-pairing/requests"');
expect(bundle).toContain('"/api/v1/device-pairing/claims"');
// And the display deliberately never asks the *human* principal endpoint: a paired device is
// anonymous to it, so consulting it would report every display in the building as signed out
// (ADR 0036). The descriptor reports the viewer instead.
expect(bundle).not.toContain('"/api/v1/principal"');
// A display never approves, lists, or revokes a device — that is the administrator's surface.
expect(bundle).not.toContain('"/api/v1/device-pairing/approvals"');
expect(bundle).not.toContain('"/api/v1/devices"');
expect(bundle).not.toContain('"/api/v1/dashboard/navigation"');
expect(bundle).not.toContain('"/api/auth/sign-in/email"');
expect(bundle).not.toContain('"/api/auth/sign-out"');
+1
View File
@@ -16,6 +16,7 @@ export default defineConfig({
"core/tests/module-delivery.integration.ts",
"apps/api/tests/api-http.integration.ts",
"apps/api/tests/initial-owner.integration.ts",
"apps/api/tests/device-pairing.integration.ts",
"apps/api/tests/module-database.integration.ts",
],
passWithNoTests: false,